From 2480163eb0566fc36e89c7d21297f3bf70492827 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 7 Aug 2026 19:40:25 +0800 Subject: [PATCH] feat(daemon): guard cross-worktree Git mutations Co-authored-by: Qwen-Coder --- docs/design/daemon-git-worktree-guard.md | 70 +++++ packages/acp-bridge/src/bridgeClient.test.ts | 6 + packages/acp-bridge/src/bridgeClient.ts | 4 + packages/acp-bridge/src/bridgeOptions.ts | 4 + .../cli/src/acp-integration/acpAgent.test.ts | 52 ++-- packages/cli/src/acp-integration/acpAgent.ts | 13 - .../serve/daemon-git-worktree-guard.test.ts | 234 +++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 281 ++++++++++++++++++ packages/cli/src/serve/run-qwen-serve.test.ts | 7 +- packages/cli/src/serve/run-qwen-serve.ts | 20 +- 10 files changed, 633 insertions(+), 58 deletions(-) create mode 100644 docs/design/daemon-git-worktree-guard.md create mode 100644 packages/cli/src/serve/daemon-git-worktree-guard.test.ts create mode 100644 packages/cli/src/serve/daemon-git-worktree-guard.ts diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md new file mode 100644 index 00000000000..b0a67feea98 --- /dev/null +++ b/docs/design/daemon-git-worktree-guard.md @@ -0,0 +1,70 @@ +# Daemon Git worktree guard + +## Context + +A daemon ACP session is owned by one bound workspace. The model shell tool +already rejects an explicit `directory` outside its effective workspace, but a +Git command can relocate itself with `-C`, `--work-tree`, or `--git-dir` while +the shell process still starts inside the workspace. This can let a daemon +agent mutate another checkout or worktree after the direct directory form was +rejected. + +## Scope + +The guard applies only to model tool execution through the managed daemon ACP +path. It does not change CLI or TUI shell validation, Git safety classification, +permission rules, confirmation behavior, or direct user shell execution. + +The daemon enables its managed tool guard for every ACP child. The host owns the +bound workspace and adds it to the validated guard request before applying the +built-in policy. An optional external tool guard remains an additional policy +and receives the same request only after the built-in policy allows it. + +## Policy + +The built-in guard inspects `run_shell_command` calls only. It recognizes Git +invocations whose repository location is changed by literal forms of: + +- `git -C ` and `git -C` +- `git --work-tree ` and `git --work-tree=` +- `git --git-dir ` and `git --git-dir=` + +Relative targets resolve from the command's effective starting directory: +`arguments.directory` when present, otherwise the session's current effective +working directory. The bridge supplies both that current directory and the +immutable bound workspace from trusted session state. The current effective +working directory is the allowed execution boundary so a session moved through +the controlled daemon `/cd` flow can operate in its selected worktree without +being mistaken for an escape from the original storage owner. + +A statically resolved Git relocation is denied when both of the following +hold: + +1. its target is outside the session's effective working directory after + canonical path resolution; +2. its Git subcommand is mutating or cannot be classified as read-only. + +Read-only relocated Git commands remain allowed. Commands with no recognized +Git relocation retain existing behavior. Dynamic relocation targets are denied +for mutating or unknown subcommands because the daemon cannot prove that the +target remains inside the effective working directory. + +`--git-dir` is evaluated by its repository directory. A target ending in +`.git` uses its parent as the repository target; linked-worktree administrative +paths are still outside the bound workspace and are denied for mutations. + +## Failure semantics + +Malformed managed guard requests, stale session or prompt ownership, missing +trusted workspace context, policy exceptions, and malformed external-provider +responses fail closed before execution. A built-in denial is final and is not +sent to the optional provider. + +## Non-goals + +- No changes to core `ShellTool`, `ShellToolInvocation`, shell AST parsing, + `PermissionManager`, `evaluatePermissionFlow`, or `CoreToolScheduler`. +- No new confirmation flow or linked-worktree exception. +- No restriction on direct user-entered daemon shell commands. +- No general shell interpreter or environment-variable analysis. +- No attempt to correlate a denial with a previous tool call. diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index ee62d7f5785..829d38714b8 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -247,10 +247,14 @@ describe('BridgeClient — managed external tool guard', () => { }); const entry: { sessionId: string; + workspaceCwd: string; + effectiveCwd: string; promptActive: boolean; activePromptId?: string; } = { sessionId: 'session-1', + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', promptActive: true, activePromptId: 'prompt-1', }; @@ -275,6 +279,8 @@ describe('BridgeClient — managed external tool guard', () => { toolCallId: 'call-1', toolName: 'write_file', arguments: { path: 'README.md' }, + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', }); }); diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index ca52d83f732..3f54d856d51 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -533,6 +533,8 @@ function sliceLineRange( */ export interface BridgeClientSessionEntry { sessionId: string; + workspaceCwd: string; + effectiveCwd: string; events: EventBus; artifacts: SessionArtifactStore; recordingDegraded: boolean; @@ -1245,6 +1247,8 @@ export class BridgeClient implements Client { toolCallId, toolName, arguments: args, + workspaceCwd: entry.workspaceCwd, + effectiveCwd: entry.effectiveCwd, }); const currentEntry = this.resolveEntry(sessionId); if ( diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 99e67f7d49a..af3f15b6192 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -77,6 +77,10 @@ export interface ExternalToolGuardPrepareRequest { readonly toolCallId: string; readonly toolName: string; readonly arguments: Readonly>; + /** Daemon-owned workspace identity. Never accepted from the ACP child. */ + readonly workspaceCwd?: string; + /** Daemon-owned current session working directory. */ + readonly effectiveCwd?: string; } export type ExternalToolGuardPrepareResult = diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 28f0c5a40f7..d730243dbed 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -18286,39 +18286,27 @@ describe('createManagedExternalToolGuard', () => { expect(extMethod).not.toHaveBeenCalled(); }); - it.each([ - ToolNames.AGENT, - ToolNames.WORKFLOW, - ToolNames.CREATE_SUB_SESSION, - ToolNames.SEND_MESSAGE, - ])( - 'rejects unsupported nested executor %s without contacting the provider', - async (toolName) => { - const extMethod = vi.fn(); - const guard = createManagedExternalToolGuard({ - extMethod, - } as unknown as AgentSideConnection); + it('forwards nested executors to the daemon host guard', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); - await expect( - guard({ - callId: 'call-1', - toolName, - args: {}, - signal: new AbortController().signal, - invocationContext: { - version: 1, - sessionId: 'session-1', - promptId: 'prompt-1', - }, - }), - ).resolves.toEqual({ - allowed: false, - reason: - 'Managed external tool guard v1 does not support nested or delegated agent execution.', - }); - expect(extMethod).not.toHaveBeenCalled(); - }, - ); + await expect( + guard({ + callId: 'call-1', + toolName: ToolNames.AGENT, + args: {}, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledOnce(); + }); it('stops waiting when the tool invocation is cancelled', async () => { const extMethod = vi.fn( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e5be8851751..101dd962262 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2807,19 +2807,6 @@ export function createManagedExternalToolGuard( if (context.signal.aborted) { throw new DOMException('Tool invocation aborted', 'AbortError'); } - if ( - context.toolName === ToolNames.AGENT || - context.toolName === ToolNames.WORKFLOW || - context.toolName === ToolNames.CREATE_SUB_SESSION || - context.toolName === ToolNames.SEND_MESSAGE - ) { - return { - allowed: false, - reason: - 'Managed external tool guard v1 does not support nested or delegated agent execution.', - }; - } - let rejectOnAbort: ((error: Error) => void) | undefined; const aborted = new Promise((_resolve, reject) => { rejectOnAbort = reject; diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts new file mode 100644 index 00000000000..b2dc1ea44a6 --- /dev/null +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { ToolNames } from '@qwen-code/qwen-code-core'; +import type { ExternalToolGuardPrepareRequest } from '@qwen-code/acp-bridge/bridgeOptions'; +import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; + +const workspaceCwd = path.resolve('workspace', 'project'); +const effectiveCwd = path.join(workspaceCwd, 'worktree'); +const outsideRepo = path.join(path.parse(effectiveCwd).root, 'outside', 'repo'); + +function request( + command: string, + extraArguments: Record = {}, +): ExternalToolGuardPrepareRequest { + return { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command, ...extraArguments }, + workspaceCwd, + effectiveCwd, + } as ExternalToolGuardPrepareRequest; +} + +describe('createDaemonToolGuard', () => { + it.each([ + () => `git -C ${outsideRepo} reset --hard`, + () => `git -C${outsideRepo} checkout -- .`, + () => + `git --work-tree=${outsideRepo} --git-dir=${path.join(outsideRepo, '.git')} clean -fd`, + () => `git --git-dir ${path.join(outsideRepo, '.git')} commit -m x`, + () => `git --namespace foo -C ${outsideRepo} reset --hard`, + () => `git --super-prefix=foo --work-tree=${outsideRepo} clean -fd`, + ])('denies relocated mutating Git command %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('allows relocated read-only Git commands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`git -C ${outsideRepo} status --short`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + `git -C ${outsideRepo} branch -D topic`, + `git -C ${outsideRepo} remote add origin example.invalid/repo`, + ])( + 'denies relocated Git subcommands that can mutate state', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('denies dynamic repository relocation for mutating commands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C "$OTHER_WORKTREE" reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }); + + it('allows mutating Git commands inside the effective working directory', async () => { + const guard = createDaemonToolGuard(); + + await expect(guard(request('git -C nested reset --hard'))).resolves.toEqual( + { allowed: true }, + ); + }); + + it('resolves relative targets from the explicit shell directory', async () => { + const guard = createDaemonToolGuard(); + const nested = path.join(effectiveCwd, 'nested'); + + await expect( + guard(request('git -C .. reset --hard', { directory: nested })), + ).resolves.toEqual({ allowed: true }); + await expect( + guard( + request(`git -C ${path.relative(nested, outsideRepo)} reset --hard`, { + directory: nested, + }), + ), + ).resolves.toMatchObject({ allowed: false }); + }); + + it.each([ + `pwd && git -C ${outsideRepo} reset --hard; true`, + `X=1 git -C ${outsideRepo} reset --hard`, + `env X=1 git -C ${outsideRepo} reset --hard`, + `command git -C ${outsideRepo} reset --hard`, + `pwd\ngit -C ${outsideRepo} reset --hard`, + ])( + 'denies a relocated mutation inside shell command forms', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('does not treat a Git command passed as an argument as executable', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`echo git -C ${outsideRepo} reset --hard`)), + ).resolves.toEqual({ allowed: true }); + }); + + it('follows chained -C targets using Git semantics', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`git -C nested -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard( + request( + `git -C ${outsideRepo} -C ${path.relative(outsideRepo, effectiveCwd)} reset --hard`, + ), + ), + ).resolves.toEqual({ allowed: true }); + }); + + it('checks work-tree and git-dir targets independently', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard( + request( + `git --work-tree=${effectiveCwd} --git-dir=${path.join(outsideRepo, '.git')} reset --hard`, + ), + ), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('resolves a missing target through its nearest existing symlink ancestor', async () => { + const temporaryRoot = await mkdtemp( + path.join(os.tmpdir(), 'daemon-guard-'), + ); + const localEffectiveCwd = path.join(temporaryRoot, 'worktree'); + const localOutsideRepo = path.join(temporaryRoot, 'outside'); + const linkedOutsideRepo = path.join(localEffectiveCwd, 'linked-outside'); + await Promise.all([ + mkdir(localEffectiveCwd, { recursive: true }), + mkdir(localOutsideRepo, { recursive: true }), + ]); + await symlink(localOutsideRepo, linkedOutsideRepo); + + try { + const guard = createDaemonToolGuard(); + await expect( + guard({ + ...request('git -C linked-outside/missing reset --hard'), + workspaceCwd: localEffectiveCwd, + effectiveCwd: localEffectiveCwd, + }), + ).resolves.toMatchObject({ allowed: false }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + }); + + it('short-circuits the external provider after a built-in denial', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + + await expect( + guard(request(`git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + expect(externalGuard).not.toHaveBeenCalled(); + }); + + it('forwards allowed calls to the external provider unchanged', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + const call = request('pwd'); + + await expect(guard(call)).resolves.toEqual({ allowed: true }); + expect(externalGuard).toHaveBeenCalledWith(call); + }); + + it('preserves external-provider nested executor restrictions only when configured', async () => { + const call = { + ...request('pwd'), + toolName: ToolNames.AGENT, + arguments: {}, + }; + + await expect(createDaemonToolGuard()(call)).resolves.toEqual({ + allowed: true, + }); + await expect( + createDaemonToolGuard(vi.fn().mockResolvedValue({ allowed: true }))(call), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('nested or delegated'), + }); + }); + + it('fails closed without trusted daemon workspace context', async () => { + const guard = createDaemonToolGuard(); + const call = request('pwd') as unknown as Record; + delete call['effectiveCwd']; + + await expect( + guard(call as unknown as ExternalToolGuardPrepareRequest), + ).rejects.toThrow('trusted workspace context'); + }); +}); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts new file mode 100644 index 00000000000..8290aaf42e9 --- /dev/null +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { realpath } from 'node:fs/promises'; +import path from 'node:path'; +import type { + ExternalToolGuardHandler, + ExternalToolGuardPrepareRequest, + ExternalToolGuardPrepareResult, +} from '@qwen-code/acp-bridge/bridgeOptions'; +import { ToolNames } from '@qwen-code/qwen-code-core'; +import { parse } from 'shell-quote'; + +const READ_ONLY_GIT_SUBCOMMANDS = new Set([ + 'blame', + 'cat-file', + 'describe', + 'diff', + 'grep', + 'log', + 'ls-files', + 'rev-parse', + 'show', + 'status', +]); + +const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ + '-c', + '--config-env', + '--namespace', + '--super-prefix', +]); + +const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ + ToolNames.AGENT, + ToolNames.WORKFLOW, + ToolNames.CREATE_SUB_SESSION, + ToolNames.SEND_MESSAGE, +]); + +interface TrustedDaemonToolGuardRequest + extends ExternalToolGuardPrepareRequest { + readonly workspaceCwd: string; + readonly effectiveCwd: string; +} + +interface GitInvocation { + readonly relocations: Array<{ + readonly target: string; + readonly kind: 'cwd' | 'git-dir' | 'work-tree'; + }>; + readonly subcommand?: string; + readonly unresolvedRelocation: boolean; +} + +async function canonicalize(candidate: string): Promise { + const resolved = path.resolve(candidate); + let current = resolved; + const suffix: string[] = []; + while (true) { + try { + return path.join(await realpath(current), ...suffix.reverse()); + } catch { + const parent = path.dirname(current); + if (parent === current) return resolved; + suffix.push(path.basename(current)); + current = parent; + } + } +} + +function isWithin(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function readGitInvocation(tokens: string[]): GitInvocation | null { + const relocations: GitInvocation['relocations'] = []; + let unresolvedRelocation = false; + let index = 1; + while (index < tokens.length) { + const token = tokens[index]!; + if (token === '-C' || token === '--git-dir' || token === '--work-tree') { + const value = tokens[index + 1]; + if (!value) return null; + if (value.includes('$')) { + unresolvedRelocation = true; + } else { + relocations.push({ + target: value, + kind: + token === '-C' + ? 'cwd' + : token === '--git-dir' + ? 'git-dir' + : 'work-tree', + }); + } + index += 2; + continue; + } + if (token.length > 2 && token.startsWith('-C')) { + const value = token.slice(2); + if (value.includes('$')) { + unresolvedRelocation = true; + } else { + relocations.push({ target: value, kind: 'cwd' }); + } + index++; + continue; + } + if (token.startsWith('--git-dir=') || token.startsWith('--work-tree=')) { + const separator = token.indexOf('='); + const value = token.slice(separator + 1); + if (!value) return null; + if (value.includes('$')) { + unresolvedRelocation = true; + } else { + relocations.push({ + target: value, + kind: token.startsWith('--git-dir=') ? 'git-dir' : 'work-tree', + }); + } + index++; + continue; + } + if (GIT_GLOBAL_OPTIONS_WITH_VALUES.has(token)) { + index += 2; + continue; + } + if ( + token.startsWith('--config-env=') || + token.startsWith('--exec-path=') || + token.startsWith('--namespace=') || + token.startsWith('--super-prefix=') + ) { + index++; + continue; + } + if (token.startsWith('-')) { + index++; + continue; + } + return relocations.length > 0 || unresolvedRelocation + ? { relocations, subcommand: token, unresolvedRelocation } + : null; + } + return relocations.length > 0 || unresolvedRelocation + ? { relocations, unresolvedRelocation } + : null; +} + +function readCommandSegments(command: string): string[][] { + const segments: string[][] = []; + try { + for (const line of command.split(/\r?\n/)) { + const parsed = parse(line, (key) => `$${key}`); + segments.push([]); + for (const token of parsed) { + if (typeof token === 'string') { + segments.at(-1)!.push(token); + } else if ('op' in token) { + segments.push([]); + } else { + return []; + } + } + } + return segments.filter((segment) => segment.length > 0); + } catch { + return []; + } +} + +function findGitInvocationStart(tokens: string[]): number { + let index = 0; + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? '')) index++; + if (tokens[index] === 'command') { + index++; + while (tokens[index]?.startsWith('-')) index++; + } else if (tokens[index] === 'env') { + index++; + while ( + tokens[index]?.startsWith('-') || + /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? '') + ) { + index++; + } + } + return tokens[index] === 'git' ? index : -1; +} + +async function evaluateBuiltInGuard( + request: TrustedDaemonToolGuardRequest, +): Promise { + if (request.toolName !== 'run_shell_command') return { allowed: true }; + const command = request.arguments['command']; + if (typeof command !== 'string') return { allowed: true }; + + const startDirectoryValue = request.arguments['directory']; + const startDirectory = + typeof startDirectoryValue === 'string' + ? startDirectoryValue + : request.effectiveCwd; + const canonicalEffectiveCwd = await canonicalize(request.effectiveCwd); + + for (const segment of readCommandSegments(command)) { + const invocationStart = findGitInvocationStart(segment); + if (invocationStart < 0) continue; + const invocation = readGitInvocation(segment.slice(invocationStart)); + if ( + !invocation || + READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') + ) { + continue; + } + if (invocation.unresolvedRelocation) { + return { + allowed: false, + reason: + 'Daemon shell guard denied a mutating Git command with a dynamic repository location.', + }; + } + + let gitCwd = startDirectory; + const repositoryTargets: string[] = []; + for (const relocation of invocation.relocations) { + const target = path.resolve(gitCwd, relocation.target); + if (relocation.kind === 'cwd') { + gitCwd = target; + continue; + } + repositoryTargets.push( + relocation.kind === 'git-dir' && path.basename(target) === '.git' + ? path.dirname(target) + : target, + ); + } + repositoryTargets.push(gitCwd); + + for (const repositoryTarget of repositoryTargets) { + const canonicalTarget = await canonicalize(repositoryTarget); + if (isWithin(canonicalTarget, canonicalEffectiveCwd)) continue; + return { + allowed: false, + reason: `Daemon shell guard denied a mutating Git command outside the session working directory: ${canonicalTarget}`, + }; + } + } + return { allowed: true }; +} + +export function createDaemonToolGuard( + externalGuard?: ExternalToolGuardHandler, +): ExternalToolGuardHandler { + return async (request) => { + const trusted = request as TrustedDaemonToolGuardRequest; + if ( + typeof trusted.workspaceCwd !== 'string' || + typeof trusted.effectiveCwd !== 'string' + ) { + throw new Error('Daemon tool guard requires trusted workspace context.'); + } + const builtInDecision = await evaluateBuiltInGuard(trusted); + if (!builtInDecision.allowed || !externalGuard) return builtInDecision; + if (EXTERNAL_GUARD_UNSUPPORTED_TOOLS.has(request.toolName)) { + return { + allowed: false, + reason: + 'Managed external tool guard v1 does not support nested or delegated agent execution.', + }; + } + return externalGuard(request); + }; +} diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 41ac3c204a1..165976d48fa 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3537,11 +3537,16 @@ describe('runQwenServe runtime startup failures', () => { try { await handle.runtimeReady; const bridgeOptions = createBridge.mock.calls[0]?.[0] as - | { childEnvOverrides?: Record } + | { + childEnvOverrides?: Record; + externalToolGuard?: unknown; + } | undefined; expect(bridgeOptions?.childEnvOverrides).toMatchObject({ QWEN_SERVE_CDP_TUNNEL_OVER_WS: '1', + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD: 'required-v1', }); + expect(bridgeOptions?.externalToolGuard).toEqual(expect.any(Function)); } finally { if (originalClientMcpOverWs === undefined) { delete process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS']; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index de361454821..f7afdaeab0e 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -133,6 +133,7 @@ import type { ChannelDeliveryHostResult, ExternalToolGuardHandler, } from '@qwen-code/acp-bridge/bridgeOptions'; +import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; import { getCliVersion } from '../utils/version.js'; import { getRateLimiter } from './rate-limit.js'; import type { AcpHttpHandle } from './acp-http/index.js'; @@ -2797,6 +2798,9 @@ async function runQwenServeImpl( 'qwen serve: required external tool guard handshake succeeded.', ); } + const daemonToolGuardHandler = createDaemonToolGuard( + externalToolGuardHandler, + ); const childEnvOverrides: Record = { QWEN_SERVE_MCP_CLIENT_BUDGET: opts.mcpClientBudget !== undefined @@ -2804,9 +2808,7 @@ async function runQwenServeImpl( : undefined, QWEN_SERVE_MCP_BUDGET_MODE: opts.mcpBudgetMode, QWEN_SERVE_CDP_TUNNEL_OVER_WS: opts.cdpTunnelOverWs ? '1' : undefined, - [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: externalToolGuardHandler - ? EXTERNAL_TOOL_GUARD_REQUIRED_VALUE - : undefined, + [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, }; const cliVersionPromise = getCliVersion(); @@ -3923,9 +3925,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: daemonTelemetry, ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), @@ -4322,9 +4322,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory: secondaryChannelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: createRuntimeBridgeTelemetry(secondaryWorkspaceHash), ...(permissionPolicy !== undefined ? { permissionPolicy } : {}), @@ -4871,9 +4869,7 @@ async function runQwenServeImpl( sessionShellCommandEnabled, childEnvOverrides, channelFactory: wsChannelFactory, - ...(externalToolGuardHandler - ? { externalToolGuard: externalToolGuardHandler } - : {}), + externalToolGuard: daemonToolGuardHandler, onDiagnosticLine: diagnosticSink, telemetry: createRuntimeBridgeTelemetry(wsHash), ...(permissionPolicy !== undefined ? { permissionPolicy } : {}),