feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615) - #4713
Conversation
📋 Review SummaryThis PR implements approval gating for untrusted MCP server sources ( 🔍 General Feedback
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| return value; | ||
| }); | ||
|
|
||
| return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16); |
There was a problem hiding this comment.
[Critical] SHA-256 truncated to 16 hex characters (64 bits). Since the attacker controls .mcp.json (both the "initial approved" config and the later "modified" config), they can use a birthday attack to find a collision pair in approximately 2^32 SHA-256 evaluations — under 30 seconds on modern hardware. The attacker varies innocuous fields (e.g. env values, extra headers like X-Request-Id) to generate collision candidates while keeping the displayed summary identical.
This completely undermines the security guarantee of hash-bound approval: an attacker crafts two configs that hash to the same 64-bit value — one benign (to get initial approval) and one malicious (pushed later to silently bypass the approval gate).
Note: The Claude Code reference uses truncated hashes for reload detection (where collisions are harmless), not for security-critical approval binding. The threat models differ.
| return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16); | |
| return crypto.createHash('sha256').update(stable).digest('hex'); |
— claude-opus-4-6 via Qwen Code /review
| (choice: McpApprovalChoice) => { | ||
| const approvals = loadMcpApprovals(); | ||
| const root = config.getWorkingDir(); | ||
| setQueue((q) => { |
There was a problem hiding this comment.
[Suggestion] Side effects (disk I/O via approvals.setState → fs.writeFileSync, and MCP reconnect via reconnect → discoverToolsForServer) are performed inside the setQueue((q) => {...}) state updater function. React's contract specifies updater functions must be pure — React may invoke them more than once (StrictMode, concurrent features). If double-invoked, this fires duplicate disk writes and duplicate server connection attempts.
Move side effects out of the updater. Read queue from the closure and perform I/O after the state transition:
const handleMcpApprovalSelect = useCallback(
(choice: McpApprovalChoice) => {
const approvals = loadMcpApprovals();
const root = config.getWorkingDir();
const current = queue[0];
if (!current) return;
if (choice === McpApprovalChoice.APPROVE_ALL) {
for (const server of queue) {
approvals.setState(root, server.name, server.config, 'approved');
reconnect(server.name);
}
setQueue([]);
} else if (choice === McpApprovalChoice.APPROVE) {
approvals.setState(root, current.name, current.config, 'approved');
reconnect(current.name);
setQueue((q) => q.slice(1));
} else {
approvals.setState(root, current.name, current.config, 'rejected');
setQueue((q) => q.slice(1));
}
},
[config, reconnect, queue],
);— claude-opus-4-6 via Qwen Code /review
|
|
||
| return { | ||
| ...belowProject, | ||
| ...loadProjectMcpServers(cwd).servers, |
There was a problem hiding this comment.
[Suggestion] loadProjectMcpServers(cwd).errors is discarded here. If .mcp.json has a JSON syntax error, missing mcpServers key, or non-object entries, zero project servers load with no user-visible feedback. The user sees no MCP tools and no error — a silent failure that is very hard to diagnose.
Surface errors to the user (e.g. via stderr or a startup warning):
| ...loadProjectMcpServers(cwd).servers, | |
| const projectResult = loadProjectMcpServers(cwd); | |
| for (const err of projectResult.errors) { | |
| // eslint-disable-next-line no-console | |
| console.error(`Warning: ${err}`); | |
| } | |
| return { | |
| ...belowProject, | |
| ...projectResult.servers, | |
| ...aboveProject, | |
| ...(cliMcpServers ?? {}), | |
| }; |
— claude-opus-4-6 via Qwen Code /review
| errors.push(`${filePath}: server "${name}" is not an object — skipped`); | ||
| continue; | ||
| } | ||
| servers[name] = { |
There was a problem hiding this comment.
[Suggestion] A .mcp.json server entry named "__proto__" triggers the Object.prototype.__proto__ setter when assigned to this plain object. The entry is silently dropped from all downstream operations (Object.keys/entries/spread won't see it), making it invisible to the approval pipeline.
Use Object.create(null) for the accumulator to avoid prototype-inherited setters:
| servers[name] = { | |
| servers[name] = { |
Or change the declaration at line 84 to:
const servers: Record<string, MCPServerConfig> = Object.create(null);— claude-opus-4-6 via Qwen Code /review
| (name: string) => { | ||
| config.approveMcpServerForSession(name); | ||
| const registry = config.getToolRegistry(); | ||
| void registry?.discoverToolsForServer?.(name)?.catch?.(() => {}); |
There was a problem hiding this comment.
[Suggestion] reconnect swallows ALL errors from discoverToolsForServer with .catch?.(() => {}). If the MCP server binary is missing, the transport times out, or the server crashes on startup, there is zero user-facing feedback. The dialog closes, the server appears "approved", but no tools materialize.
At minimum, log the error so it surfaces in debug mode:
| void registry?.discoverToolsForServer?.(name)?.catch?.(() => {}); | |
| void registry?.discoverToolsForServer?.(name)?.catch?.((err: unknown) => { | |
| if (process.env['DEBUG']) { | |
| // eslint-disable-next-line no-console | |
| console.error(`MCP reconnect failed for ${name}:`, err); | |
| } | |
| }); |
— claude-opus-4-6 via Qwen Code /review
| // torn down if a prior pass had connected it. | ||
| if ( | ||
| cliConfig.isMcpServerDisabled(name) || | ||
| cliConfig.isMcpServerPendingApproval?.(name) |
There was a problem hiding this comment.
[Suggestion] The pending-approval guard is tested only on the discoverAllMcpTools bulk path (line ~979). Three other guard sites lack dedicated tests:
discoverAllMcpToolsIncremental(here, ~1632): the teardown of a previously-connected server that becomes pending mid-sessionreadResourcelazy-spawn guard (~2012) and re-check (~2096)discoverMcpToolsForServerInternal(~1151): single-server rediscovery path
These are security-critical paths — a regression could leave an untrusted server connected after its config changed, or allow lazy-spawning a pending-approval server via resource read. Add parity tests mirroring the existing disabled-server tests for each path.
— claude-opus-4-6 via Qwen Code /review
| config: MCPServerConfig, | ||
| status: McpApprovalStatus, | ||
| ): void { | ||
| const root = normalizeProjectRoot(projectRoot); |
There was a problem hiding this comment.
[Suggestion] setState does no structural validation on the per-project record before mutating. If mcpApprovals.json is corrupted with a non-object value under a project key (e.g. { "/home/user/proj": "garbage" }), this line does const project = this.file.config[root] ?? {} which gets the string "garbage" (truthy, so ?? doesn't fire), then project[serverName] = {...} throws a TypeError in strict mode — crashing the approval dialog.
| const root = normalizeProjectRoot(projectRoot); | |
| const existing = this.file.config[root]; | |
| const project: Record<string, McpApprovalRecord> = | |
| existing && typeof existing === 'object' && !Array.isArray(existing) | |
| ? (existing as Record<string, McpApprovalRecord>) | |
| : {}; | |
| project[serverName] = { hash: hashMcpServerConfig(config), status }; | |
| this.file.config[root] = project; |
— claude-opus-4-6 via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
[Critical · typecheck] 21 TypeScript errors across 3 test files — build fails.
packages/cli/src/commands/mcp.test.ts: 5 errors —mcpCommand.builderpossibly undefined / not callable (lines 23, 38)packages/cli/src/commands/mcp/list.test.ts: 11 errors —Cannot find namespace 'vi'(lines 43–64). Likely missingimport type { Mock } from 'vitest'or vitest types not in tsconfig.packages/cli/src/config/config.test.ts: 5 errors —'mcpServers' is possibly 'undefined'(lines 2107, 2112),Object is possibly 'undefined'(lines 2125, 2139), andcontextPercentageThresholddoes not exist onChatCompressionSettings(line 2493).
— qwen3.7-max via Qwen Code /review
|
|
||
| export function getMcpApprovalsPath(): string { | ||
| if (process.env['QWEN_CODE_MCP_APPROVALS_PATH']) { | ||
| return process.env['QWEN_CODE_MCP_APPROVALS_PATH']; |
There was a problem hiding this comment.
[Critical] QWEN_CODE_MCP_APPROVALS_PATH is read from process.env here but is NOT in PROJECT_ENV_HARDCODED_EXCLUSIONS in settings.ts. A malicious repository can include a .env file that sets this variable to an attacker-controlled path containing pre-approved entries for every server in .mcp.json. Attack flow: (1) user clones repo, (2) trusts the folder, (3) loadEnvironment reads .env and sets the env var, (4) getMcpApprovalsPath() returns the attacker's path, (5) all malicious servers appear pre-approved and auto-connect without any approval dialog.
This completely bypasses the approval gate — the primary security control this PR introduces.
| return process.env['QWEN_CODE_MCP_APPROVALS_PATH']; | |
| // NB: QWEN_CODE_MCP_APPROVALS_PATH must be in PROJECT_ENV_HARDCODED_EXCLUSIONS | |
| // in settings.ts to prevent project .env from redirecting the approvals store. | |
| if (process.env['QWEN_CODE_MCP_APPROVALS_PATH']) { | |
| return process.env['QWEN_CODE_MCP_APPROVALS_PATH']; | |
| } |
Also add 'QWEN_CODE_MCP_APPROVALS_PATH' to PROJECT_ENV_HARDCODED_EXCLUSIONS in settings.ts.
— qwen3.7-max via Qwen Code /review
| errors.push({ message: getErrorMessage(error), path: filePath }); | ||
| } | ||
|
|
||
| loadedMcpApprovals = new LoadedMcpApprovals( |
There was a problem hiding this comment.
[Critical] loadMcpApprovals() captures parse/IO errors into errors, but getPendingProjectMcpServers() (and all other callers) never reads this field. When the approvals file is corrupted (truncated by crash, partial write, disk error), the function returns an empty config, getState() returns 'pending' for every gated server, and all previously-approved servers silently revert to pending — with no stderr warning, no log line, and no UI message.
This is the most likely production issue: "Every MCP server stopped working and is asking for approval again" with no diagnostic trail to explain why.
Suggested fix: Surface errors at load time:
loadedMcpApprovals = new LoadedMcpApprovals(
{ path: filePath, config },
errors,
);
for (const err of errors) {
writeStderrLine(`Warning: MCP approvals file error: ${err.message}`);
}
return loadedMcpApprovals;— qwen3.7-max via Qwen Code /review
| } | ||
| } | ||
| return pending; | ||
| } |
There was a problem hiding this comment.
[Critical] saveMcpApprovals uses raw fs.writeFileSync instead of atomicWriteFile from packages/core/src/utils/atomicFileWrite.ts (used in 68+ call sites across the codebase). If the process is killed mid-write (Ctrl-C during the approval dialog, SIGKILL, power loss), the approvals file can be left truncated or containing partial JSON. Combined with the silent error handling above, this creates a silent total-loss scenario: the next startup reads a corrupt file, loses ALL approval decisions, and tells nobody.
The "Approve All" path calls setState in a loop (N sequential writes for N servers), widening the crash window.
| } | |
| export async function saveMcpApprovals(file: { | |
| path: string; | |
| config: McpApprovalsConfig; | |
| }): Promise<void> { | |
| try { | |
| const dirPath = path.dirname(file.path); | |
| if (!fs.existsSync(dirPath)) { | |
| fs.mkdirSync(dirPath, { recursive: true }); | |
| } | |
| await atomicWriteFile(file.path, JSON.stringify(file.config, null, 2), { | |
| mode: 0o600, | |
| }); | |
| } catch (error) { | |
| writeStderrLine('Error saving MCP approvals file.'); | |
| writeStderrLine(error instanceof Error ? error.message : String(error)); | |
| } | |
| } |
Note: this makes saveMcpApprovals async, which cascades to setState and callers — but all callers are already in async contexts.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| const mcpServers = (parsed as { mcpServers?: unknown })?.mcpServers; | ||
| if (!mcpServers || typeof mcpServers !== 'object') { |
There was a problem hiding this comment.
[Suggestion] The guard checks typeof mcpServers !== 'object' but doesn't check Array.isArray(mcpServers). Since typeof [] === 'object', a .mcp.json with "mcpServers": [...] (array instead of object) passes the guard. Object.entries on an array yields [['0', elem0], ['1', elem1], ...], producing phantom servers with numeric names like "0", "1".
The inner Array.isArray(value) check on individual entries catches array-typed elements but not the array-typed parent.
| if (!mcpServers || typeof mcpServers !== 'object') { | |
| if (!mcpServers || typeof mcpServers !== 'object' || Array.isArray(mcpServers)) { | |
| return { | |
| servers: {}, | |
| path: filePath, | |
| errors: [`${filePath} has no "mcpServers" object`], | |
| }; |
— qwen3.7-max via Qwen Code /review
| // are not approved are listed WITHOUT connecting — inspecting an untrusted | ||
| // config must stay side-effect-free (#4615). Only approved / non-gated | ||
| // servers get a live connection test. | ||
| if (isGatedMcpScope(server.scope)) { |
There was a problem hiding this comment.
[Suggestion] This gated-scope branch (showing "Pending approval" / "Rejected" without connecting) is the core user-visible behavior of qwen mcp list for untrusted servers, but list.test.ts has zero tests exercising it. All four existing test servers lack scope: 'project' or scope: 'workspace', so isGatedMcpScope() always returns false in the test suite. Additionally, loadMcpApprovals and assembleMcpServers are not mocked — existing tests silently call real implementations.
A regression could silently attempt to connect untrusted .mcp.json servers during listing, violating the side-effect-free guarantee.
Suggested fix: Add tests for: (1) a project-scoped pending server shows "Pending approval" without calling createTransport; (2) a rejected workspace server shows "Rejected". Mock assembleMcpServers and loadMcpApprovals to control test inputs.
— qwen3.7-max via Qwen Code /review
| const pendingMcpServers = | ||
| bareMode || !interactive | ||
| ? undefined | ||
| : getPendingProjectMcpServers(mcpServers, cwd); |
There was a problem hiding this comment.
[Suggestion] This is the most security-critical branch in the PR — it determines whether gated servers auto-connect or require approval — yet config.test.ts has zero tests for pendingMcpServers or the non-interactive bypass path (grep for both terms returns no matches).
Without a test, a refactor of the interactive or bareMode detection could silently change the security posture of headless sessions in either direction: auto-approve when it shouldn't, or block when it should auto-approve.
Suggested fix: Add two tests: (1) interactive session with a .mcp.json server verifying isMcpServerPendingApproval(name) returns true; (2) non-interactive session (stdin not TTY) verifying isMcpServerPendingApproval(name) returns false.
— qwen3.7-max via Qwen Code /review
| if (!current) { | ||
| return; | ||
| } | ||
| if (choice === McpApprovalChoice.APPROVE_ALL) { |
There was a problem hiding this comment.
[Suggestion] When the user selects "Approve all," the hook iterates the entire queue approving every server, but MCPServerApprovalDialog only displays the first server's name and summary. The user never sees the command, args, or httpUrl of subsequent servers before the blanket approval.
A malicious .mcp.json could declare one legitimate-looking server followed by several malicious ones. The user reviews the first, hits "Approve all," and the malicious servers connect without any scrutiny.
Suggested fix: List all pending server names + summaries in the dialog before the radio buttons, or add a confirmation step for "Approve all" that shows what will be approved:
Approving all will connect these servers:
slack node slack.js (stdio)
telemetry curl example.com (http)
— qwen3.7-max via Qwen Code /review
| } | ||
| } | ||
|
|
||
| function summarize(config: MCPServerConfig): string { |
There was a problem hiding this comment.
[Suggestion] summarize() duplicates the same httpUrl→(http) / url→(sse) / command args→(stdio) logic that already exists in formatServerCommand() at packages/cli/src/ui/components/mcp/utils.ts:103-116. The only differences are cosmetic (.replace(/\s+\(/, ' (') vs .trim()).
Three copies of the same formatting logic means a future transport type requires edits in three places, and the slight drift already produces marginally different output for edge cases.
Suggested fix: Extract a shared formatMcpServerSummary(config: MCPServerConfig): string into mcp/utils.ts and reuse from all three call sites.
— qwen3.7-max via Qwen Code /review
| * returned list is what the discovery layer skips | ||
| * (`Config.isMcpServerPendingApproval`). See issue #4615. | ||
| */ | ||
| export function getPendingProjectMcpServers( |
There was a problem hiding this comment.
[Suggestion] The function name says "Project" but it iterates ALL gated scopes via isGatedMcpScope(config.scope), which returns true for both 'project' AND 'workspace'. Workspace-scoped servers (from .qwen/settings.json) are also included in the returned list.
A caller reading getPendingProjectMcpServers might assume workspace-scoped servers are excluded and add a separate workspace check, creating redundant or incorrect logic.
| export function getPendingProjectMcpServers( | |
| export function getPendingGatedMcpServers( |
— qwen3.7-max via Qwen Code /review
| * stdio spawn / transport / health check, so inspecting an untrusted | ||
| * `.mcp.json` has no side effects. See issue #4615. | ||
| */ | ||
| isMcpServerPendingApproval(serverName: string): boolean { |
There was a problem hiding this comment.
[Critical] getFailedMcpServerNames() (line 1884) skips disabled servers but does NOT skip pending-approval servers. Since the discovery layer never connects pending servers (status stays DISCONNECTED), getMCPServerStatus(name) !== CONNECTED is true for them, and they get included in the "failed" list.
Callers (gemini.tsx, acpAgent.ts, nonInteractive/session.ts) then emit "Warning: MCP server(s) failed to start: slack" — misleading diagnostics for servers that are merely awaiting approval, not actually broken.
This also means the surfaceFailuresOnce path in AppContainer.tsx may fire during the approval dialog, before the user has even had a chance to decide.
| isMcpServerPendingApproval(serverName: string): boolean { | |
| isMcpServerPendingApproval(serverName: string): boolean { | |
| return this.pendingMcpServers?.includes(serverName) ?? false; | |
| } | |
| // In getFailedMcpServerNames() (line 1891), add after the disabled check: | |
| // if (this.isMcpServerPendingApproval(name)) continue; |
— qwen3.7-max via Qwen Code /review
| throw new Error('mcp command builder must be a function'); | ||
| } | ||
| const builtYargs = builder(yargsInstance); | ||
| const options = builtYargs.getOptions(); |
There was a problem hiding this comment.
[Critical] TypeScript error: Property 'getOptions' does not exist on type 'Argv<{}> | PromiseLike<Argv<{}>>'. The builder function (typed as CommandModule['builder']) can return Argv | PromiseLike<Argv>. The runtime typeof builder !== 'function' guard narrows it to a function, but the return type is still the union.
| const options = builtYargs.getOptions(); | |
| const builtYargs = await builder(yargsInstance); | |
| const options = builtYargs.getOptions(); |
(Make the test callback async and await the builder result to unwrap the PromiseLike.)
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Both R2 Critical issues are fixed: getFailedMcpServerNames() now filters pending-approval servers (config.ts:1902), and the mcp.test.ts tsc error is resolved (async builder). The "Approve all" dialog now lists all pending servers for transparency. tsc and eslint clean, 217 tests pass across 5 MCP-related suites. LGTM! ✅ — qwen3.7-max via Qwen Code /review
Verification Report — PR #4713Commit: Test Results
Typecheck NoteThe 3 errors ( Test File Breakdown
Execution Environment
VerdictAll tests pass — 670 total (275 core + 395 cli). ESLint clean across 15 source files. The typecheck errors are from a cross-branch dependency (#4485 @google/genai upgrade) merged into this branch but not yet on main — not introduced by this PR's changes. No regressions detected. |
| if (cliConfig.isMcpServerDisabled(name)) { | ||
| debugLogger.debug(`Skipping disabled MCP server: ${name}`); | ||
| // A project server (`.mcp.json`) that is pending approval — or that | ||
| // became pending mid-session because its config changed (#4615) — is |
There was a problem hiding this comment.
[Suggestion] Comment says servers can "become pending mid-session because its config changed (#4615)" but Config.isMcpServerPendingApproval checks this.pendingMcpServers, a startup-time string[] snapshot. The only mutator approveMcpServerForSession removes entries — nothing adds names back. No mechanism re-reads .mcp.json hashes mid-session. A server approved at startup that later has its config edited would NOT be caught by this check.
Fix: Correct the comment to say "A project server that was pending at startup" or implement mid-session re-evaluation (e.g., re-hash against the approval store on health-check ticks).
— qwen3.7-max via Qwen Code /review
| return `${config.command} ${config.args?.join(' ') ?? ''} (stdio)`.replace( | ||
| /\s+\(/, | ||
| ' (', | ||
| ); |
There was a problem hiding this comment.
[Suggestion] summarize() only shows command/args/url in the approval dialog, hiding env and headers. A malicious .mcp.json entry with {"env": {"LD_PRELOAD": "/evil.so"}} would display as node server.js (stdio), hiding the payload from the user making the trust decision.
Consider showing env var key names (or at least a count) in the summary:
if (config.env && Object.keys(config.env).length > 0) {
parts.push(`[+${Object.keys(config.env).length} env vars]`);
}— qwen3.7-max via Qwen Code /review
| Array.isArray(parsed) | ||
| ) { | ||
| errors.push({ | ||
| message: 'MCP approvals file is not a valid JSON object.', |
There was a problem hiding this comment.
[Suggestion] Two error-handling branches in loadMcpApprovals() lack test coverage: (1) when the JSON value is a non-object (e.g., '[1,2,3]'), hitting the "not a valid JSON object" path here; (2) when JSON is malformed, hitting the catch block at line 145. The existing corruption test only covers per-project record corruption, not these top-level parse failures. These are the first branches a corrupted approvals file would hit — adding tests ensures graceful degradation works as intended.
— qwen3.7-max via Qwen Code /review
| * `discoverToolsForServer` connects it instead of skipping it. See issue | ||
| * #4615. No-op for servers that were never pending. | ||
| */ | ||
| approveMcpServerForSession(serverName: string): void { |
There was a problem hiding this comment.
[Suggestion] approveMcpServerForSession() has no direct unit test. The hook test (useMcpApproval.test.ts) uses a mock (vi.fn()), not the real filter logic. No test verifies that after calling approveMcpServerForSession('a'), isMcpServerPendingApproval('a') returns false while isMcpServerPendingApproval('b') remains true, and that calling it for a non-pending server is a no-op.
— qwen3.7-max via Qwen Code /review
Local Verification ReportPR: #4713 Test Results
CLI Test Failure Analysis
Pre-existing Core Failure
Typecheck DeltaCLI 113 errors vs local Verdict✅ PASS — All PR-specific tests pass (279 core + 402 CLI). No new test failures, no new typecheck errors, no lint issues. Verified locally by wenshao |
| const project: Record<string, McpApprovalRecord> = | ||
| existing && typeof existing === 'object' && !Array.isArray(existing) | ||
| ? existing | ||
| : {}; |
There was a problem hiding this comment.
[Suggestion] setState uses {} (inheriting from Object.prototype) as the fallback for the per-project approval record. When serverName is '__proto__', the assignment project[serverName] = { hash, status } triggers the Object.prototype.__proto__ setter — it calls Object.setPrototypeOf(project, {hash, status}) instead of creating an own property. Since JSON.stringify only serializes own enumerable properties, the __proto__ server's approval decision is silently dropped from the persisted file, reverting to pending on every restart.
The same class of bug was already fixed in mcpJson.ts:81 using Object.create(null), but the fix was not applied here. Consider also guarding the existing branch (which comes from JSON.parse and also inherits Object.prototype):
| : {}; | |
| : Object.create(null); |
And for the existing branch, use Object.defineProperty when serverName is __proto__ or constructor to prevent prototype pollution.
— qwen3.7-max via Qwen Code /review
|
Hi @qqqys — heads up that this branch is currently showing merge conflicts with Once it's conflict-free and CI is green I'll do a full review pass. Thanks! |
f04455e to
8ff1360
Compare
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
✅ Independent Verification Report — PR #4713Built and ran real tests locally for this MCP approval-gating + precedence feature. All 12 affected suites pass, typecheck + eslint are clean, and a full end-to-end approval lifecycle works in the actual built CLI. Verdict: LGTM — recommend merge (one design trade-off worth a conscious ack, below). Environment
1 — Affected unit suites ✅ 691 passed / 2 skipped (12 files)The 2 skips are pre-existing
2 — Typecheck + lint ✅ clean3 — Real end-to-end gating lifecycle in the built CLI ✅ 11/11Isolated via ① Pending & not connected (no side effects): → ② Approve → persisted with the documented shape: {
"/tmp/pr4713-proj": {
"demo-server": { "hash": "a8a13c17…34f2", "status": "approved" }
}
}keyed by ③ Hash binding (the core #4615 property) — edit → config edit changes the hash → reverts to pending, end-to-end through the real CLI. ✅ ④ Reject → This exercises the whole stack (
|
|
Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs. |
|
Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs. |
1 similar comment
|
Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs. |
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
This is a review of the rebased branch. The core of the feature is well built — on every interactive discovery path the approval check lands before any process spawn, the hash binding can't be replayed across servers or edits, and a checked-in config can't spoof its way into a trusted scope. All four issues below are about the perimeter: entry points that skip the gate entirely.
1. qwen mcp reconnect spawns pending and rejected servers as a side effect (severity: high · confidence: very high)
The reconnect command builds its own minimal config without the approval gate and then starts discovery for every configured server. Running it with any server name — even an unrelated, already-approved one — background-spawns every pending and rejected workspace-gated server along the way. The known-limitations note in the description frames this as an explicit user action on one server, but the servers actually spawned are not the one the user named, and a rejected server starting up is the opposite of what the recorded decision promises. It also frames this as a .mcp.json concern, but it's actually the workspace .qwen/settings.json servers that are exposed here (.mcp.json servers aren't visible to reconnect at all).
2. ACP/IDE and headless sessions auto-run untrusted .mcp.json servers (severity: high · confidence: high)
The gate only exists for interactive terminal sessions. Under ACP (Zed/IDE integrations) and in headless runs the pending set is never computed, so every gated server auto-approves — and this PR newly introduces .mcp.json loading into exactly those paths. Net effect: opening a cloned repo in an IDE with qwen as the agent spawns the repo's checked-in MCP servers at session start, with no prompt and no record — precisely the threat this feature exists to stop. The "session servers are trusted" rationale covers servers the IDE injects, not checked-in config loaded into the IDE session.
3. Explicit rejections are not honored outside interactive mode (severity: high · confidence: very high)
A rejection recorded via qwen mcp reject (or Esc in the dialog, which persists one) is only enforced in interactive sessions. A subsequent -p, piped, SDK, or ACP run never consults the approvals store and connects the rejected server anyway. Lenient auto-approval for servers nobody has decided on yet is the documented trade-off; silently overriding a decision the user explicitly recorded is a different and stronger failure.
4. Approving from the startup dialog bypasses folder trust (severity: medium · confidence: high)
Approving a server in the dialog immediately starts it through a single-server discovery path that doesn't check folder trust, while bulk discovery does. In an untrusted folder the dialog can still appear (project .mcp.json is loaded regardless of trust), so one approval there spawns a server the folder-trust gate would otherwise have held back. Approval and folder trust are meant to be independent gates; this path lets one override the other.
Verdict
COMMENT — the interactive gate and approval store are solid, but the unguarded entry points (reconnect, ACP/headless, dialog-in-untrusted-folder) undercut the guarantee the feature is making, and the headless path overrides explicit rejections. These are worth addressing before merge.
11ae426 to
e8f4ac0
Compare
Local runtime verification (maintainer-run)Verdict: PASS — built this PR locally and drove the real CLI/TUI in tmux against a live stdio MCP server. Every behavior claimed in the description held up at the user-facing surface. Findings below are non-blocking. Method. Exercised at the running app (~30 steps)Approval gate & side-effect freedom
Interactive TUI (tmux)
Precedence (each proven by which marker file got spawned)
Robustness probes
Selected raw capturesStartup dialog (tmux pane): Fail-closed headless run (rejected server): Hash-bound store ( {
"/private/tmp/qwen4713/proj": {
"repo-tools": { "hash": "2d1e…70a6", "status": "rejected" },
"ws-tools": { "hash": "746d…a91e", "status": "approved" }
}
}E2E tool call through an approved Spawn-marker timeline (every line = one real process spawn): Findings (non-blocking)
Not exercised at runtime: ACP/IDE |
| const settings = loadSettings(); | ||
| const cwd = process.cwd(); | ||
| const fileService = new FileDiscoveryService(cwd); | ||
| const mcpServers = await getMcpServersFromConfig(); |
There was a problem hiding this comment.
Bug: getMcpServersFromConfig() does not call assembleMcpServers(), so project .mcp.json servers are invisible to qwen mcp reconnect.
Consequences:
qwen mcp reconnect <project-server>fails with "Server not found" for.mcp.jsonserversqwen mcp reconnect --allsilently skips all project serverspendingMcpServerscomputed below is incomplete (missing project-scope entries)
The list command was correctly updated to use assembleMcpServers (see list.ts), but reconnect was not. Consider either:
- Using
assembleMcpServers(settings.merged.mcpServers, cwd)here (matchinglist.ts), or - Updating
getMcpServersFromConfigitself to callassembleMcpServersso both call sites benefit.
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Hi @qqqys — thanks for the detailed PR! The implementation looks thorough, but the PR body doesn't follow our pull request template.
The template requires these specific headings (in order):
## What this PR does## Why it's needed## Reviewer Test Plan(with### How to verify,### Evidence (Before & After),### Tested on)## Risk & Scope## Linked Issues<details>Chinese translation
Your current body uses custom headings (## Summary, ## Precedence, ## Approval / trust model, ## Testing, ## Known limitations / follow-ups) which don't map to the template. This makes it harder for reviewers to quickly find the information they need (especially the reviewer test plan and evidence).
Could you restructure the body to follow the template? The good news is the content is all there — it just needs reorganizing under the right headings. The ## Summary content maps naturally to ## What this PR does + ## Why it's needed, and the ## Testing section has great detail that fits nicely into ### How to verify and ### Evidence.
Thanks! 🙏
中文说明
@qqqys 你好——感谢这个详尽的 PR!不过 PR 正文没有按照模板的格式。
模板要求以下标题(按顺序):
## What this PR does## Why it's needed## Reviewer Test Plan(含### How to verify、### Evidence (Before & After)、### Tested on)## Risk & Scope## Linked Issues<details>中文翻译
当前正文使用了自定义标题(## Summary、## Precedence 等),需要按模板重新组织。内容本身很好,只是需要放到正确的标题下。## Summary 对应 ## What this PR does + ## Why it's needed,## Testing 的详细测试信息适合放到 ### How to verify 和 ### Evidence 下。
谢谢!
— Qwen Code · qwen3.7-max
|
@qwen-code /triage |
|
Thanks for the PR, @qqqys! Template looks good ✓ On direction: this is squarely aligned with qwen-code's security mission. Checked-in On approach: the scope feels right for what it delivers. The hash binding is the core safety property — without it, an approval would silently inherit edits. The precedence model (user < project < workspace/system < session < CLI) is clean and matches Claude Code's layering. The split into One small observation: the Moving on to code review and testing. 🔍 中文说明感谢贡献,@qqqys! 模板完整 ✓ 方向:完全对齐 qwen-code 的安全使命。提交的 方案:范围与交付匹配。Hash 绑定是核心安全属性——没有它,审批会静默继承编辑。优先级模型(user < project < workspace/system < session < CLI)清晰且匹配 Claude Code 的分层。没有看到更简路径能在保留 hash 绑定不变量的同时达到同等效果。 一个小观察: 进入代码审查和测试 🔍 — Qwen Code · qwen3.7-max |
Code ReviewThe implementation is well-structured and security-conscious. A few observations from reading the diff: Strengths:
No blockers found. The code follows project conventions, is appropriately scoped, and the security model holds up under scrutiny. Real-Scenario TestingAll tests run from Persistence check — {
"/tmp/triage-test-4713": {
"test-server": {
"hash": "8a032dc0212c2e073ead1c2e101c21d332fef5773fd3ee8824c24acb4d7b3dfa",
"status": "rejected"
}
}
}All five scenarios verified:
Unit tests: 740+ passing across both packages (core + cli). 中文说明代码审查实现结构清晰,安全意识到位。几个观察: 优点:
未发现阻塞性问题。 代码遵循项目规范,范围适当,安全模型经得起审查。 真实场景测试全部五个场景验证通过:
单元测试:core + cli 两个包 740+ 测试全部通过。 — Qwen Code · qwen3.7-max |
|
This PR does exactly what it sets out to do, and does it cleanly. The security model is sound: hash-bound approvals mean a tampered My independent proposal before reading the diff would have been: add a SHA-256 hash of the server config, store approve/reject decisions keyed by (projectRoot, serverName, hash), and gate in the MCP discovery pipeline. The PR's approach matches this and exceeds it with the scope/provenance model (user < project < workspace < session < CLI) and the interactive startup dialog for batch approval. Real-scenario testing confirmed all five critical paths: pending gating, approval with hash persistence, config-tamper detection (hash mismatch → revert to pending), rejection persistence, and clean baseline when no Approving. ✅ 中文说明这个 PR 完成了它设定的目标,而且实现得很干净。 安全模型合理:哈希绑定审批意味着被篡改的 我在阅读 diff 之前的独立方案是:添加服务器配置的 SHA-256 哈希,以 (projectRoot, serverName, hash) 为键存储审批/拒绝决策,并在 MCP 发现管道中进行门控。PR 的方案与此匹配,并通过范围/来源模型(user < project < workspace < session < CLI)和交互式启动批量审批对话框超越了基本方案。 真实场景测试确认了全部五个关键路径:待审批门控、带哈希持久化的审批、配置篡改检测(哈希不匹配→恢复为待审批)、拒绝持久化,以及无 批准合并。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
… scope precedence (#4615) (#4713) * feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615) Adds untrusted-source approval gating for MCP servers and a coherent cross-source precedence model. Sources & precedence (low -> high): user/default settings < project .mcp.json < workspace/system settings < session(ACP/IDE) < --mcp-config - Load project servers from .mcp.json (pure read, never connects), tagged scope:'project'. - Tag workspace/system settings servers with provenance scope at merge time so the winning entry keeps its source; centralize assembly in assembleMcpServers. - Gate checked-in/shareable sources (project + workspace) behind a hash-bound approval store; .mcp.json edits revert approval to pending. system/user/CLI/ extension/session sources are never gated. - .mcp.json now overrides USER settings (Claude parity) but never enterprise 'system' settings. - Route ACP/IDE-injected servers through a top-tier sessionMcpServers param so a repo .mcp.json can't override or gate them. - Startup approval dialog + 'qwen mcp approve|reject' + 'qwen mcp list' cover both gated sources; non-interactive sessions auto-approve (lenient). Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): cover mcp scope stamping * fix(mcp): harden approval binding * fix(mcp): harden approval store persistence * fix(mcp): address approval review feedback * refactor(cli): rename gated MCP approval helper * fix(mcp): surface approval metadata in prompts * docs(mcp): clarify pending approval snapshot * fix(mcp): persist prototype-named approval records * test(mcp): cover pending approval guard paths * chore(mcp): refresh approval gating checks * fix(mcp): enforce approval gate outside interactive * fix(cli): label MCP server sources accurately * fix(cli): include project MCP servers in reconnect * docs(cli): correct MCP approval noninteractive note --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
What this PR does
Adds approval gating for untrusted, checked-in MCP server sources and a coherent cross-source precedence model, aligning qwen-code with Claude Code's
.mcp.jsonhandling while preserving enterprise and session-level trust boundaries.The gated sources are project
.mcp.jsonfiles and workspace.qwen/settings.jsonmcpServers. Servers from those shared repo-level files are held behind a per-server, hash-bound approval decision before discovery can connect to them. Effective MCP server precedence is now assembled as: user/default settings < project.mcp.json< workspace/system settings < session (ACP/IDE) <--mcp-config. Project config can override user config, but it cannot override enterprise-enforced system settings; session-injected servers remain explicit, per-session, and ungated.Approval decisions persist in
<QWEN_HOME>/mcpApprovals.json, keyed by project root and server name, and bound to a canonical config hash. Editing a server changes the hash and returns it to pending.qwen mcp approve [name|--all],qwen mcp reject [name|--all], andqwen mcp listexpose the approval state without connecting pending or rejected gated servers.Why it's needed
Checked-in MCP configuration can spawn arbitrary local commands or connect to external services. Without a trust gate, opening a repository with a malicious
.mcp.jsonor workspace settings file can trigger side effects before the user has reviewed the server. This PR makes those shared sources explicit trust decisions, while keeping user, system, extension, CLI, and session-supplied servers working as trusted sources.The hash binding is the core safety property: an approval applies only to the reviewed server configuration. If the repo changes the command, args, URL, environment, or headers, the server becomes pending again instead of silently inheriting the old decision.
Reviewer Test Plan
How to verify
Reviewers should confirm that project
.mcp.jsonand workspace-scoped servers appear asPending approvalorRejectedwithout connecting, that approving a server persists a hash-bound decision, that editing the server returns it to pending, and that rejecting a server prevents connection across interactive, headless, ACP/IDE, andqwen mcp reconnectentry points. Reviewers should also confirm precedence: project.mcp.jsonoverrides user settings, workspace overrides project, system remains above project, and--mcp-configoverrides all persistent sources.Run the targeted unit suites for MCP config loading, approval storage, precedence assembly, list/approve/reject commands, config pending checks, and the approval UI hook. The PR has also been validated with full workspace typecheck and eslint plus core MCP suites.
Evidence (Before & After)
Before: checked-in project/workspace MCP server definitions could be discovered through entry points that did not consistently consult a persisted approval decision, and PR description did not follow the repository template.
After: gated project/workspace servers are skipped before any process spawn or transport connection until an approved hash-bound decision exists; explicit rejections are honored on every entry point; reconnect uses the assembled server map; approving from the startup dialog does not bypass the independent folder-trust gate; the PR body now follows the required template.
Tested on
Environment (optional)
Local and maintainer verification reports covered Node 22, package-specific Vitest suites, TypeScript typecheck, eslint, and real CLI approval lifecycle checks with isolated
QWEN_HOMEand scratch repositories.Risk & Scope
Linked Issues
Refs #4615.
中文说明
What this PR does
本 PR 为不可信、可随仓库提交的 MCP server 来源增加审批门控,并建立统一的跨来源优先级模型,使 qwen-code 与 Claude Code 的
.mcp.json行为对齐,同时保留企业级和会话级信任边界。被门控的来源包括项目
.mcp.json和 workspace.qwen/settings.json里的mcpServers。这些共享的仓库级配置声明的 server,必须先经过按 server、按配置 hash 绑定的审批决策,发现层才会连接。最终 MCP server 优先级为:user/default settings < project.mcp.json< workspace/system settings < session (ACP/IDE) <--mcp-config。项目配置可以覆盖用户配置,但不能覆盖企业强制的 system 配置;session 注入的 server 仍然是显式、按会话生效且不被门控的可信来源。审批决策持久化在
<QWEN_HOME>/mcpApprovals.json,按 project root 和 server name 建索引,并绑定规范化配置 hash。修改 server 配置会改变 hash,使它重新回到 pending。qwen mcp approve [name|--all]、qwen mcp reject [name|--all]和qwen mcp list会展示审批状态,且不会连接 pending 或 rejected 的 gated server。Why it's needed
提交到仓库里的 MCP 配置可以启动任意本地命令或连接外部服务。如果没有信任门控,用户打开包含恶意
.mcp.json或 workspace settings 的仓库时,server 可能在用户审查前就产生副作用。本 PR 把这些共享来源变成显式信任决策,同时保持 user、system、extension、CLI 和 session 提供的 server 作为可信来源正常工作。Hash 绑定是核心安全属性:一次审批只适用于用户已审查过的那份 server 配置。如果仓库修改了 command、args、URL、environment 或 headers,该 server 会重新变成 pending,而不是静默继承旧审批。
Reviewer Test Plan
How to verify
评审者应确认 project
.mcp.json和 workspace scope 的 server 会显示为Pending approval或Rejected,且不会被连接;确认 approve 会持久化 hash 绑定决策;确认修改 server 后会回到 pending;确认 reject 会在 interactive、headless、ACP/IDE 和qwen mcp reconnect等入口都阻止连接。还应确认优先级:project.mcp.json覆盖 user settings,workspace 覆盖 project,system 保持高于 project,--mcp-config覆盖所有持久来源。建议运行 MCP 配置加载、审批存储、优先级组装、list/approve/reject 命令、config pending 检查和审批 UI hook 的定向单测。本 PR 也已通过 full workspace typecheck、eslint 和 core MCP suites 的验证。
Evidence (Before & After)
Before:checked-in 的 project/workspace MCP server 定义可能通过某些入口被发现,而这些入口没有一致查询持久化审批决策;PR 描述也没有遵循仓库模板。
After:gated project/workspace server 在没有 approved 的 hash 绑定决策前,会在任何 process spawn 或 transport connection 之前被跳过;显式 reject 会在所有入口生效;reconnect 使用组装后的 server map;startup dialog 的 approve 不会绕过独立的 folder-trust gate;PR 正文已按要求改为模板结构。
Tested on
Environment (optional)
本地和 maintainer 验证报告覆盖了 Node 22、package-specific Vitest suites、TypeScript typecheck、eslint,以及使用隔离
QWEN_HOME和临时仓库的真实 CLI 审批生命周期检查。Risk & Scope
Linked Issues
Refs #4615.