From 2480163eb0566fc36e89c7d21297f3bf70492827 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 7 Aug 2026 19:40:25 +0800 Subject: [PATCH 01/45] 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 } : {}), From 2b0e8cdcf7660a3851a5ca80bcf4e145c8105e38 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 7 Aug 2026 20:06:08 +0800 Subject: [PATCH 02/45] fix(daemon): keep Git guard off serve fast path Co-authored-by: Qwen-Coder --- .../src/serve/daemon-git-worktree-guard.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 8290aaf42e9..66e1f192ffc 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -11,8 +11,6 @@ import type { 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', @@ -34,11 +32,11 @@ const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ '--super-prefix', ]); -const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ - ToolNames.AGENT, - ToolNames.WORKFLOW, - ToolNames.CREATE_SUB_SESSION, - ToolNames.SEND_MESSAGE, +const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ + 'agent', + 'workflow', + 'create_sub_session', + 'send_message', ]); interface TrustedDaemonToolGuardRequest @@ -156,9 +154,13 @@ function readGitInvocation(tokens: string[]): GitInvocation | null { : null; } -function readCommandSegments(command: string): string[][] { +let shellQuotePromise: Promise | undefined; + +async function readCommandSegments(command: string): Promise { const segments: string[][] = []; try { + shellQuotePromise ??= import('shell-quote'); + const { parse } = await shellQuotePromise; for (const line of command.split(/\r?\n/)) { const parsed = parse(line, (key) => `$${key}`); segments.push([]); @@ -210,7 +212,7 @@ async function evaluateBuiltInGuard( : request.effectiveCwd; const canonicalEffectiveCwd = await canonicalize(request.effectiveCwd); - for (const segment of readCommandSegments(command)) { + for (const segment of await readCommandSegments(command)) { const invocationStart = findGitInvocationStart(segment); if (invocationStart < 0) continue; const invocation = readGitInvocation(segment.slice(invocationStart)); From b175cd72fbbfed55911600fd59c3402423f0c6c5 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 7 Aug 2026 17:02:15 +0000 Subject: [PATCH 03/45] fix(serve): close daemon Git guard parser bypasses Rebuild the daemon-side Git relocation guard parser so the runtime-verified bypasses from review are closed: comment/glob tokens, backslash continuations, shell wrapper and path-qualified invocations, cwd-shifting builtins, env-var relocations, gitfile/symlink/worktree-admin indirection, -C vs relative git-dir ordering, --output and textconv-capable read-only subcommands, command-valued -c config, and dynamic expansion forms all fail closed for mutations outside the session working directory. Command splitting, canonicalization, and containment now reuse the core helpers. Key the child-side v1 restrictions (/fork, agent-backed workspace memory) and per-call daemon round trips on a real external provider being attached instead of on guard plumbing presence: under the built-in guard alone, hidden-agent tool calls traverse the same daemon-side policy, so those features stay available and non-shell tools resolve locally. Denial reasons are length-clamped and control-character-stripped so they always satisfy the guard result validation. --- docs/design/daemon-git-worktree-guard.md | 76 +- docs/developers/qwen-serve-protocol.md | 74 +- docs/users/qwen-serve.md | 42 + packages/acp-bridge/src/bridgeClient.test.ts | 45 + packages/acp-bridge/src/externalToolGuard.ts | 18 + .../cli/src/acp-integration/acpAgent.test.ts | 182 ++- packages/cli/src/acp-integration/acpAgent.ts | 41 +- packages/cli/src/gemini.tsx | 19 + .../serve/daemon-git-worktree-guard.test.ts | 467 ++++++- .../src/serve/daemon-git-worktree-guard.ts | 1116 ++++++++++++++--- packages/cli/src/serve/run-qwen-serve.test.ts | 28 +- packages/cli/src/serve/run-qwen-serve.ts | 5 + 12 files changed, 1838 insertions(+), 275 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index b0a67feea98..fdc57ef8fd5 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -22,12 +22,29 @@ 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: +The built-in guard inspects `run_shell_command` calls only. Command splitting +reuses core `splitCommands`; containment reuses core `realpathNearestExisting` +and `isWithinRoot`. 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=` +- leading `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, or `GIT_INDEX_FILE` + assignments +- directory-shifting wrapper flags `env -C`/`--chdir` and `sudo -D`/`--chdir` +- `cd`, `pushd`, or `popd` builtins earlier in the same command chain, whose + targets become the containment basis for later Git invocations in that chain + +Wrapper prefixes are unwrapped before Git detection: leading env assignments, +`command`, `env` (with its value-taking flags), `sudo` (with its value-taking +flags), `nohup`, `exec`, `timeout `, `sh|bash|dash|zsh|ksh -c` +payloads (analyzed recursively), `eval` payloads (analyzed recursively, with +cwd changes propagated because `eval` runs in the current shell), +path-qualified Git binaries by basename, and leading `{`/`!` shell syntax. +A segment whose program token cannot be classified (shell expansions) fails +closed when the segment also carries a Git relocation marker or a recorded +relocation. Relative targets resolve from the command's effective starting directory: `arguments.directory` when present, otherwise the session's current effective @@ -35,7 +52,10 @@ 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. +being mistaken for an escape from the original storage owner. Git applies `-C` +during option parsing and resolves relative `--git-dir`/`--work-tree` against +the post-`-C` cwd, so relative targets resolve against the final cwd of the +`-C` chain regardless of argv order. A statically resolved Git relocation is denied when both of the following hold: @@ -44,21 +64,48 @@ hold: 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 +Relocated commands whose subcommand is in a small verified read-only set +(`status`, `rev-parse`, `ls-files`, `grep`, `describe`, `cat-file`) remain +allowed. `diff`, `log`, `show`, and `blame` are excluded from that set: +`--output` writes files, and textconv-style drivers execute programs +configured by the target repository. Any `--output` flag demotes an +invocation. Commands with no recognized relocation retain existing behavior. +Dynamic relocation targets (`$` expansions, backticks, leading `~`, globs) +and command-executing `-c`/`--config-env` assignments (`alias.*`, +`core.editor`, `core.pager`, `credential.helper`, `filter.*`, `difftool.*`, +`mergetool.*`, `core.fsmonitor`, or values starting with `!`) 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. +`--git-dir` is evaluated by the repository git operates on, with +canonicalization before basename handling: a target whose canonical form ends +in `.git` uses its parent; a `.git` gitfile is followed through its `gitdir:` +redirect; a per-worktree administrative directory +(`/.git/worktrees/`) is resolved through its `gitdir` file to the +linked worktree checkout. Unresolvable indirections fail closed. ## 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. +responses fail closed before execution. Unparseable commands, dangling +relocation options, relocation targets that do not fully exist at decision +time (a missing target can still become an outward symlink before git runs), +and unreadable Git indirections are denied for mutating or unclassifiable +subcommands. A built-in denial is final and is not sent to the optional +provider. Denial reasons are length-clamped and control-character-stripped so +they always satisfy the guard result validation. + +The managed guard plumbing is active for every daemon ACP child because the +built-in policy needs it. The child-side v1 restrictions (`/fork` and +agent-backed workspace memory remember/dream) key on the external provider +being attached, not on the plumbing's mere presence: under the built-in guard +alone, hidden-agent tool calls traverse the same managed guard and are +inspected by the same daemon-side policy. Without a provider the child also +resolves every non-shell tool call locally (the built-in policy allows them +structurally) instead of paying a child-daemon-child round trip per call; +`run_shell_command` always makes the round trip. With a provider attached +every call still makes it. ## Non-goals @@ -66,5 +113,10 @@ sent to the optional provider. `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 general shell interpreter or environment-variable analysis: script files + run by `bash script.sh` or `source` are not read, and variable values are + not tracked across commands. +- No heredoc body analysis: Git-shaped text inside a heredoc is scanned as + executable lines and can be denied even though the shell never executes it + (a fail-closed false positive, not a bypass). - No attempt to correlate a denial with a previous tool call. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index da4c486e1b8..47f3d6e75bc 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -422,43 +422,43 @@ operator diagnostic snapshot documented below. -| Tag | Advertised when … | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | -| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | -| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | -| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected. | -| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | -| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | -| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | -| `workspace_settings` | the daemon was created with settings persistence available. | -| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | -| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | -| `session_shell_command` | session shell execution is explicitly enabled. | -| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | -| `session_generation` | session generation helpers are available. | -| `workspace_generation` | workspace-scoped generation helpers are available. | -| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | -| `workspace_reload` | workspace reload support is available in the embedded route configuration. | -| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | -| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | -| `channel_control` | daemon-managed channel worker runtime control is wired. | -| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | -| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | -| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | -| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | -| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | -| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | -| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | -| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | -| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | -| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | -| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | -| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | -| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | -| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | -| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | -| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | +| Tag | Advertised when … | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | +| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | +| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to managed `run_shell_command` invocations, so the absence of this tag does not mean no pre-execution denials. | +| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | +| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | +| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | +| `workspace_settings` | the daemon was created with settings persistence available. | +| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | +| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | +| `session_shell_command` | session shell execution is explicitly enabled. | +| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | +| `session_generation` | session generation helpers are available. | +| `workspace_generation` | workspace-scoped generation helpers are available. | +| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | +| `workspace_reload` | workspace reload support is available in the embedded route configuration. | +| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | +| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | +| `channel_control` | daemon-managed channel worker runtime control is wired. | +| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | +| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | +| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | +| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | +| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | +| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | +| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | +| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | +| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | +| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | +| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | +| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | +| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | +| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | +| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | +| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index d45b5de96aa..85b7aca5daf 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -432,6 +432,48 @@ Notes: > matched case-sensitively by yargs `choices` (`--memory-project-scope Workspace` is rejected). Use lowercase values when copying between the two. +### Built-in daemon Git relocation guard + +Every managed daemon ACP session applies a built-in pre-execution guard for +model shell commands, independent of `--external-tool-guard-mode` and without +any capability advertisement. The daemon owns the bound workspace and the +session's current effective working directory; both are supplied from trusted +session state and never accepted from the ACP child. + +The guard inspects `run_shell_command` invocations and denies a mutating Git +command before execution when its repository location resolves outside the +session's effective working directory. Relocation is recognized for literal +forms of `git -C `, `git --git-dir[=]`, +`git --work-tree[=]`, leading +`GIT_DIR`/`GIT_WORK_TREE`/`GIT_COMMON_DIR`/`GIT_INDEX_FILE` assignments, +directory-shifting wrapper flags (`env -C`, `sudo -D`), and `cd`, `pushd`, or +`popd` builtins earlier in the same command chain. Common wrapper prefixes +(`sh -c`, `bash -c`, `eval`, `sudo`, `nohup`, `timeout`, `exec`, `command`, +`env`, path-qualified `git` binaries, and `{ …; }` / `! …` shell syntax) are +unwrapped so the same policy applies to the inner Git invocation. + +Relative targets resolve from the command's effective starting directory +(`arguments.directory` when present, otherwise the session's current effective +working directory) after canonical path resolution, including `.git` gitfile +redirects, symlinks, and per-worktree administrative directories. A relocated +target that cannot be fully resolved before execution — a dynamic target +(`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an +unreadable indirection — is denied for mutating or unclassifiable subcommands. +Relocated commands whose subcommand is one of a small verified read-only set +(`status`, `rev-parse`, `ls-files`, `grep`, `describe`, `cat-file`) remain +allowed. Commands with no recognized relocation keep their existing behavior. +Denials are final and are reported to the model as +`Daemon shell guard denied a mutating Git command…`. + +The guard is a static best-effort policy: it does not interpret script files, +track environment variable values across commands, or analyze heredoc bodies +(Git-shaped text inside a heredoc can be denied even though the shell never +executes it). `/fork` and agent-backed workspace memory remember/dream remain +available under the built-in guard; they are only restricted while the +external provider mode below is active. An optional external tool guard +remains an additional policy and receives the same request only after the +built-in policy allows it. + ### Required external Tool Guard This opt-in is for managed ACP deployments that need an external allow/deny diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 829d38714b8..1c4b9686003 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -284,6 +284,51 @@ describe('BridgeClient — managed external tool guard', () => { }); }); + it('ignores forged workspace fields in the child payload', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + 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', + }; + const client = makeClient(undefined, { + resolveEntry: (sessionId) => + sessionId === entry.sessionId ? entry : undefined, + handler, + }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'write_file', + arguments: { path: 'README.md' }, + workspaceCwd: '/forged/workspace', + effectiveCwd: '/forged/effective', + }), + ).resolves.toEqual({ allowed: true }); + expect(handler).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'write_file', + arguments: { path: 'README.md' }, + workspaceCwd: '/workspace', + effectiveCwd: '/workspace/worktree', + }); + }); + it('rejects a stale prompt without contacting the host', async () => { const handler = vi.fn().mockResolvedValue({ allowed: true, diff --git a/packages/acp-bridge/src/externalToolGuard.ts b/packages/acp-bridge/src/externalToolGuard.ts index 9e65d3420f5..64a4eb463db 100644 --- a/packages/acp-bridge/src/externalToolGuard.ts +++ b/packages/acp-bridge/src/externalToolGuard.ts @@ -12,6 +12,18 @@ export const PRIVATE_EXTERNAL_TOOL_GUARD_ENV = 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD'; +/** + * Private, non-secret marker passed from `qwen serve` to its ACP child only + * when a real external tool guard provider is attached. Without it the child + * still installs the managed guard plumbing (the daemon's built-in policy + * needs it), but resolves every non-shell tool locally and keeps `/fork` and + * agent-backed workspace memory available: those features are only disabled + * for the external provider's v1 contract, which cannot observe hidden-agent + * execution. + */ +export const PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV = + 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER'; + /** * ACP initialize-response metadata proving that the child consumed the * private activation marker and installed the required executor callback. @@ -26,6 +38,12 @@ export const EXTERNAL_TOOL_GUARD_READY_META_KEY = */ export const EXTERNAL_TOOL_GUARD_REQUIRED_VALUE = 'required-v1'; +/** + * The provider-attached marker value `qwen serve` passes to the child when a + * real external tool guard provider is configured. + */ +export const EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE = 'attached-v1'; + /** Daemon-local bearer token for the loopback external Tool Guard provider. */ export const EXTERNAL_TOOL_GUARD_TOKEN_ENV = 'QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index d730243dbed..71cd5b20270 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -8226,7 +8226,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('rejects agent-backed workspace memory operations when the managed guard is required', async () => { + it('rejects agent-backed workspace memory operations when an external guard provider is attached', async () => { Object.assign(mockConfig, { isManagedMemoryAvailable: vi.fn().mockReturnValue(true), getProjectRoot: vi.fn().mockReturnValue('/workspace'), @@ -8239,6 +8239,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { privateParentCapability: 'expected-capability', externalToolGuardRequired: true, + externalToolGuardProviderAttached: true, }, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); @@ -8281,6 +8282,46 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('keeps agent-backed workspace memory available under the built-in guard alone', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + + await expect( + agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, + {}, + ), + ).resolves.toEqual({ available: true }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('launches fork agents with neutral history text', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -8358,7 +8399,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('rejects /fork before starting a nested agent when the managed guard is required', async () => { + it('rejects /fork before starting a nested agent when an external guard provider is attached', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); const execute = vi.fn(); @@ -8384,6 +8425,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { privateParentCapability: 'expected-capability', externalToolGuardRequired: true, + externalToolGuardProviderAttached: true, }, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); @@ -8428,6 +8470,62 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('allows /fork past the guard gate under the built-in guard alone', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const execute = vi.fn().mockResolvedValue({ llmContent: 'ok' }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getHistoryShallow: vi + .fn() + .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), + addHistory: vi.fn(), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn(() => ({ build })), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + sessionId, + directive: 'review this branch', + }), + ).resolves.toMatchObject({ launched: true }); + expect(build).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('allows cancelling paused agent tasks', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -18271,9 +18369,12 @@ describe('createManagedExternalToolGuard', () => { it('fails closed without a managed invocation context', async () => { const extMethod = vi.fn(); - const guard = createManagedExternalToolGuard({ - extMethod, - } as unknown as AgentSideConnection); + const guard = createManagedExternalToolGuard( + { + extMethod, + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); await expect( guard({ @@ -18286,7 +18387,32 @@ describe('createManagedExternalToolGuard', () => { expect(extMethod).not.toHaveBeenCalled(); }); - it('forwards nested executors to the daemon host guard', async () => { + it('forwards nested executors to the daemon host guard when a provider is attached', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard( + { + extMethod, + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); + + 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('resolves non-shell tools locally when only the built-in guard is attached', async () => { const extMethod = vi.fn().mockResolvedValue({ allowed: true }); const guard = createManagedExternalToolGuard({ extMethod, @@ -18305,6 +18431,41 @@ describe('createManagedExternalToolGuard', () => { }, }), ).resolves.toEqual({ allowed: true }); + await expect( + guard({ + callId: 'call-2', + toolName: 'write_file', + args: {}, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).not.toHaveBeenCalled(); + }); + + it('still routes shell commands to the daemon without an external provider', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); expect(extMethod).toHaveBeenCalledOnce(); }); @@ -18312,9 +18473,12 @@ describe('createManagedExternalToolGuard', () => { const extMethod = vi.fn( () => new Promise>(() => {}), ); - const guard = createManagedExternalToolGuard({ - extMethod, - } as unknown as AgentSideConnection); + const guard = createManagedExternalToolGuard( + { + extMethod, + } as unknown as AgentSideConnection, + { externalProviderAttached: true }, + ); const controller = new AbortController(); const pending = guard({ callId: 'call-1', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 101dd962262..b5ab1d85698 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -304,6 +304,7 @@ import { EXTERNAL_TOOL_GUARD_TOKEN_ENV, isValidExternalToolGuardDenialReason, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import { parseSessionSource, @@ -2796,8 +2797,22 @@ export async function deliverClientMcpMessage( */ export function createManagedExternalToolGuard( connection: AgentSideConnection, + options: { externalProviderAttached: boolean } = { + externalProviderAttached: false, + }, ): ToolInvocationGuard { return async (context) => { + // With only the daemon's built-in policy attached there is no external + // provider to consult: every non-shell tool is structurally allowed, + // so resolve locally instead of paying a serialized child-daemon-child + // round trip on every tool call. `run_shell_command` still goes to the + // daemon because that is the only tool the built-in policy inspects. + if ( + !options.externalProviderAttached && + context.toolName !== 'run_shell_command' + ) { + return { allowed: true }; + } const invocation = context.invocationContext; if (!invocation) { throw new Error( @@ -2981,6 +2996,7 @@ export async function runAcpAgent( options?: { privateParentCapability?: string; externalToolGuardRequired?: boolean; + externalToolGuardProviderAttached?: boolean; }, ) { // Freeze the restart-required writer protocol before the first await. @@ -2996,8 +3012,11 @@ export async function runAcpAgent( : options.privateParentCapability; delete process.env[PRIVATE_ACP_CAPABILITY_ENV]; delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_ENV]; + delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]; delete process.env[EXTERNAL_TOOL_GUARD_TOKEN_ENV]; const externalToolGuardRequired = options?.externalToolGuardRequired === true; + const externalToolGuardProviderAttached = + options?.externalToolGuardProviderAttached === true; if (externalToolGuardRequired && privateParentCapability === undefined) { throw new Error( 'Required external tool guard is available only to a private managed ACP parent.', @@ -3125,7 +3144,9 @@ export async function runAcpAgent( connection = new AgentSideConnection((conn) => { acpConnection = conn; const managedToolInvocationGuard = externalToolGuardRequired - ? createManagedExternalToolGuard(conn) + ? createManagedExternalToolGuard(conn, { + externalProviderAttached: externalToolGuardProviderAttached, + }) : undefined; agentInstance = new QwenAgent( config, @@ -3135,6 +3156,7 @@ export async function runAcpAgent( privateParentCapability, sessionWriterLeaseEnabledAtStartup, managedToolInvocationGuard, + externalToolGuardProviderAttached, ); return agentInstance; }, stream); @@ -3647,7 +3669,10 @@ class QwenAgent implements Agent { } private rejectUnsupportedGuardedHiddenAgent(operation: string): void { - if (this.managedToolInvocationGuard) { + if ( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) { throw RequestError.invalidParams( undefined, `Managed external tool guard v1 does not support ${operation}.`, @@ -4381,6 +4406,7 @@ class QwenAgent implements Agent { private readonly expectedPrivateParentCapability?: string, private readonly sessionWriterLeaseEnabledAtStartup = false, private readonly managedToolInvocationGuard?: ToolInvocationGuard, + private readonly externalToolGuardProviderAttached = false, ) { // Pool kill switch via env var so operators can A/B compare or // roll back without rebuilding. `run-qwen-serve.ts` sets this when @@ -8140,8 +8166,10 @@ class QwenAgent implements Agent { case SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability: return { available: - !this.managedToolInvocationGuard && - this.config.isManagedMemoryAvailable(), + !( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) && this.config.isManagedMemoryAvailable(), }; case SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember: { this.rejectUnsupportedGuardedHiddenAgent( @@ -9975,7 +10003,10 @@ class QwenAgent implements Agent { return { sessionId, answer: result.text || null }; } case SERVE_CONTROL_EXT_METHODS.sessionForkAgent: { - if (this.managedToolInvocationGuard) { + if ( + this.managedToolInvocationGuard && + this.externalToolGuardProviderAttached + ) { throw RequestError.invalidParams( undefined, 'Managed external tool guard v1 does not support /fork.', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4a651107179..928ba4e9ca9 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -22,9 +22,11 @@ import { uiTelemetryService, } from '@qwen-code/qwen-code-core'; import { + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, EXTERNAL_TOOL_GUARD_TOKEN_ENV, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import dns from 'node:dns'; import fs from 'node:fs'; @@ -365,6 +367,12 @@ export async function main() { ? EXTERNAL_TOOL_GUARD_REQUIRED_VALUE : undefined; delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_ENV]; + const privateExternalToolGuardProvider = + process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV] === + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE + ? EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE + : undefined; + delete process.env[PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]; if (process.argv.includes('--bare')) { process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1'; @@ -391,6 +399,12 @@ export async function main() { ...(privateExternalToolGuard ? { [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: privateExternalToolGuard, + ...(privateExternalToolGuardProvider + ? { + [PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]: + privateExternalToolGuardProvider, + } + : {}), } : {}), } @@ -1042,6 +1056,11 @@ export async function main() { isAcpMode && privateAcpParentCapability !== undefined && privateExternalToolGuard === EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, + externalToolGuardProviderAttached: + isAcpMode && + privateAcpParentCapability !== undefined && + privateExternalToolGuardProvider === + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, }); // Clean up child processes and force exit, matching other non-interactive modes await runExitCleanup(); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index b2dc1ea44a6..b1939b4f7d4 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -4,17 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, 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 temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-')); +const workspaceCwd = path.join(temporaryRoot, 'workspace'); const effectiveCwd = path.join(workspaceCwd, 'worktree'); -const outsideRepo = path.join(path.parse(effectiveCwd).root, 'outside', 'repo'); +const insideNested = path.join(effectiveCwd, 'nested'); +const outsideRepo = path.join(temporaryRoot, 'outside', 'repo'); +mkdirSync(path.join(outsideRepo, '.git'), { recursive: true }); +mkdirSync(insideNested, { recursive: true }); function request( command: string, @@ -31,6 +36,10 @@ function request( } as ExternalToolGuardPrepareRequest; } +afterAll(async () => { + await rm(temporaryRoot, { recursive: true, force: true }); +}); + describe('createDaemonToolGuard', () => { it.each([ () => `git -C ${outsideRepo} reset --hard`, @@ -57,6 +66,21 @@ describe('createDaemonToolGuard', () => { ).resolves.toEqual({ allowed: true }); }); + it.each([ + `git -C ${outsideRepo} diff`, + `git -C ${outsideRepo} log -p`, + `git -C ${outsideRepo} show --output=${path.join(outsideRepo, 'out.txt')} HEAD`, + ])( + 'denies relocated Git subcommands that can execute target-repo config or write files', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + it.each([ `git -C ${outsideRepo} branch -D topic`, `git -C ${outsideRepo} remote add origin example.invalid/repo`, @@ -82,6 +106,47 @@ describe('createDaemonToolGuard', () => { }); }); + it.each([ + 'git -C `echo /outside/repo` reset --hard', + 'git -C ~/repos/other-checkout reset --hard', + "git $'-C' /outside/repo reset --hard", + "$'git' -C /outside/repo reset --hard", + 'git $(echo -C) /outside/repo reset --hard', + 'git -C /outside/repo* reset --hard', + ])('denies shell-expansion relocation forms %#', async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }); + + it.each([ + // A trailing comment must not hide the relocation from the guard. + () => `git -C ${outsideRepo} reset --hard # note`, + // Git treats an empty `-C` as a no-op and applies the next relocation. + () => `git -C "" -C ${outsideRepo} reset --hard`, + ])('denies relocations masked by token edge cases %#', async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it.each(['git -C', 'git --git-dir', 'git --work-tree='])( + 'fails closed on a dangling relocation option', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + it('allows mutating Git commands inside the effective working directory', async () => { const guard = createDaemonToolGuard(); @@ -92,16 +157,18 @@ describe('createDaemonToolGuard', () => { 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 })), + guard(request('git -C .. reset --hard', { directory: insideNested })), ).resolves.toEqual({ allowed: true }); await expect( guard( - request(`git -C ${path.relative(nested, outsideRepo)} reset --hard`, { - directory: nested, - }), + request( + `git -C ${path.relative(insideNested, outsideRepo)} reset --hard`, + { + directory: insideNested, + }, + ), ), ).resolves.toMatchObject({ allowed: false }); }); @@ -123,14 +190,149 @@ describe('createDaemonToolGuard', () => { }, ); - it('does not treat a Git command passed as an argument as executable', async () => { + it.each([ + () => `sh -c 'git -C ${outsideRepo} reset --hard'`, + () => `bash -c "git -C ${outsideRepo} reset --hard"`, + () => `bash -lc 'git -C ${outsideRepo} reset --hard'`, + () => `eval 'git -C ${outsideRepo} reset --hard'`, + () => `sudo git -C ${outsideRepo} reset --hard`, + () => `nohup git -C ${outsideRepo} reset --hard`, + () => `timeout 5 git -C ${outsideRepo} reset --hard`, + () => `exec git -C ${outsideRepo} reset --hard`, + () => `/usr/bin/git -C ${outsideRepo} reset --hard`, + () => `./bin/git -C ${outsideRepo} reset --hard`, + () => `{ git -C ${outsideRepo} reset --hard; }`, + () => `! git -C ${outsideRepo} reset --hard`, + () => `env -S 'git -C ${outsideRepo} reset --hard'`, + ])( + 'denies a relocated mutation through wrapper invocations %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `cd ${outsideRepo} && git reset --hard`, + () => `pushd ${outsideRepo} && git reset --hard`, + () => `(cd ${outsideRepo} && git reset --hard)`, + () => `eval 'cd ${outsideRepo}' && git reset --hard`, + () => 'cd && git reset --hard', + () => 'cd - && git reset --hard', + () => 'popd && git reset --hard', + ])( + 'denies mutations after a cwd-shifting builtin %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps subshell cwd shifts from leaking into later commands', async () => { const guard = createDaemonToolGuard(); await expect( - guard(request(`echo git -C ${outsideRepo} reset --hard`)), + guard(request(`sh -c 'cd ${outsideRepo}'; git reset --hard`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`cd ${effectiveCwd} && git reset --hard`)), ).resolves.toEqual({ allowed: true }); }); + it.each([ + () => `git -C \\ +${outsideRepo} reset --hard`, + () => `g\\ +it -C ${outsideRepo} reset --hard`, + ])( + 'joins backslash continuations before parsing %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it.each([ + () => `GIT_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `GIT_WORK_TREE=${outsideRepo} git reset --hard`, + () => `GIT_COMMON_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `env GIT_DIR=${path.join(outsideRepo, '.git')} git reset --hard`, + () => `env -C ${outsideRepo} git reset --hard`, + () => `env --chdir=${outsideRepo} git reset --hard`, + () => `env -u GIT_DIR git -C ${outsideRepo} reset --hard`, + () => `sudo -D ${outsideRepo} git reset --hard`, + ])( + 'denies repository relocation through environment forms %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + `git --git-dir=../evil/.git -C .. branch X`, + `git -C .. --git-dir=../evil/.git branch X`, + `git --work-tree=../evil -C .. reset --hard`, + ])( + 'resolves relative git-dir and work-tree against the final -C cwd', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + `git -c alias.pwn='!git -C ${outsideRepo} branch pwned' pwn`, + 'git -c core.editor=evil-command commit', + 'git --config-env core.pager=evil-command log --follow', + 'git -c filter.evil.clean=evil-command add file', + ])( + 'denies mutating subcommands with command-valued -c config', + async (command) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(command))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }, + ); + + it('allows harmless -c config on mutations inside the boundary', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -c user.name=Qwen commit --allow-empty')), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on commands that cannot be parsed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C ${UNBALANCED reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('could not be parsed'), + }); + }); + it('follows chained -C targets using Git semantics', async () => { const guard = createDaemonToolGuard(); @@ -159,11 +361,8 @@ describe('createDaemonToolGuard', () => { }); 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 localEffectiveCwd = path.join(temporaryRoot, 'sym-cwd'); + const localOutsideRepo = path.join(temporaryRoot, 'sym-outside'); const linkedOutsideRepo = path.join(localEffectiveCwd, 'linked-outside'); await Promise.all([ mkdir(localEffectiveCwd, { recursive: true }), @@ -171,18 +370,144 @@ describe('createDaemonToolGuard', () => { ]); 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 }); - } + const guard = createDaemonToolGuard(); + await expect( + guard({ + ...request('git -C linked-outside/missing reset --hard'), + workspaceCwd: localEffectiveCwd, + effectiveCwd: localEffectiveCwd, + }), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('denies relocated mutations whose target does not exist at decision time', async () => { + const guard = createDaemonToolGuard(); + + // The command itself can create an outward symlink before git runs, so + // a target that is missing now cannot be proven safe. + await expect( + guard(request(`ln -s ${outsideRepo} link && git -C link reset --hard`)), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('unresolvable repository location'), + }); + }); + + it('follows gitfile redirects before the containment check', async () => { + const gitfilePath = path.join(insideNested, '.git'); + await writeFile(gitfilePath, `gitdir: ${path.join(outsideRepo, '.git')}\n`); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=nested/.git branch -D topic')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + await expect( + guard(request(`GIT_DIR=nested/.git sh -c 'git reset --hard'`)), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('canonicalizes a symlink named .git before stripping the basename', async () => { + const linkDir = path.join(insideNested, 'd'); + await mkdir(linkDir, { recursive: true }); + await symlink(path.join(outsideRepo, '.git'), path.join(linkDir, '.git')); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=nested/d/.git branch -D topic')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + + it('resolves per-worktree admin directories to the linked worktree', async () => { + const adminDir = path.join(effectiveCwd, '.git', 'worktrees', 'wt1'); + const outsideCheckout = path.join(temporaryRoot, 'outside-checkout'); + await Promise.all([ + mkdir(adminDir, { recursive: true }), + mkdir(outsideCheckout, { recursive: true }), + ]); + await writeFile( + path.join(outsideCheckout, '.git'), + `gitdir: ${adminDir}\n`, + ); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(outsideCheckout, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=.git/worktrees/wt1 reset --hard')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideCheckout), + }); + }); + + it('allows per-worktree admin directories whose checkout stays inside', async () => { + const adminDir = path.join(effectiveCwd, '.git', 'worktrees', 'wt2'); + const insideCheckout = path.join(effectiveCwd, 'wt2-checkout'); + await Promise.all([ + mkdir(adminDir, { recursive: true }), + mkdir(insideCheckout, { recursive: true }), + ]); + await writeFile(path.join(insideCheckout, '.git'), `gitdir: ${adminDir}\n`); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(insideCheckout, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git --git-dir=.git/worktrees/wt2 reset --hard')), + ).resolves.toEqual({ allowed: true }); + }); + + it('clamps long paths and strips control characters in denial reasons', async () => { + const guard = createDaemonToolGuard(); + const longTarget = path.join(outsideRepo, 'x'.repeat(200), 'y'.repeat(200)); + + const longDenial = await guard( + request(`git -C ${longTarget} reset --hard`), + ); + expect(longDenial).toMatchObject({ allowed: false }); + const longReason = (longDenial as { reason: string }).reason; + expect(longReason.length).toBeLessThanOrEqual(500); + expect(longReason).toContain('…'); + + const tabTarget = path.join(temporaryRoot, 'tab\tdir'); + await mkdir(path.join(tabTarget, '.git'), { recursive: true }); + const controlDenial = await guard( + request(`git -C '${tabTarget}' reset --hard`), + ); + expect(controlDenial).toMatchObject({ allowed: false }); + const controlReason = (controlDenial as { reason: string }).reason; + expect(controlReason.length).toBeLessThanOrEqual(500); + // eslint-disable-next-line no-control-regex -- asserting control chars are stripped + expect(controlReason).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); + }); + + it('allows dynamic relocations for read-only subcommands', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C "$OTHER_WORKTREE" status')), + ).resolves.toEqual({ allowed: true }); + }); + + 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('short-circuits the external provider after a built-in denial', async () => { @@ -204,31 +529,75 @@ describe('createDaemonToolGuard', () => { expect(externalGuard).toHaveBeenCalledWith(call); }); - it('preserves external-provider nested executor restrictions only when configured', async () => { - const call = { - ...request('pwd'), - toolName: ToolNames.AGENT, - arguments: {}, + it('returns an external provider denial for an otherwise allowed call', async () => { + const providerDenial = { + allowed: false, + reason: 'Provider policy denied this invocation.', }; + const externalGuard = vi.fn().mockResolvedValue(providerDenial); + const guard = createDaemonToolGuard(externalGuard); - 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'), - }); + await expect(guard(request('pwd'))).resolves.toEqual(providerDenial); + expect(externalGuard).toHaveBeenCalledOnce(); }); - it('fails closed without trusted daemon workspace context', async () => { - const guard = createDaemonToolGuard(); - const call = request('pwd') as unknown as Record; - delete call['effectiveCwd']; + it.each([ + ToolNames.AGENT, + ToolNames.WORKFLOW, + ToolNames.CREATE_SUB_SESSION, + ToolNames.SEND_MESSAGE, + ])( + 'preserves external-provider nested executor restrictions only when configured (%s)', + async (toolName) => { + const call = { + ...request('pwd'), + toolName, + 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'), + }); + }, + ); - await expect( - guard(call as unknown as ExternalToolGuardPrepareRequest), - ).rejects.toThrow('trusted workspace context'); + // The unsupported-tool set intentionally pins ToolNames string literals so + // this module keeps its import footprint; a rename must fail here. + it('matches the ToolNames constants for nested executor tools', () => { + const unsupported = new Set([ + 'agent', + 'workflow', + 'create_sub_session', + 'send_message', + ]); + expect(unsupported).toEqual( + new Set([ + ToolNames.AGENT, + ToolNames.WORKFLOW, + ToolNames.CREATE_SUB_SESSION, + ToolNames.SEND_MESSAGE, + ]), + ); }); + + it.each(['workspaceCwd', 'effectiveCwd'])( + 'fails closed without trusted daemon workspace context (%s)', + async (field) => { + const guard = createDaemonToolGuard(); + const call = request('pwd') as unknown as Record; + delete call[field]; + + 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 index 66e1f192ffc..aa4d2846e8b 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -4,34 +4,110 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { realpath } from 'node:fs/promises'; +import { realpath, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; +import { parse } from 'shell-quote'; +import { + isWithinRoot, + realpathNearestExisting, + splitCommands, +} from '@qwen-code/qwen-code-core'; +import { EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS } from '@qwen-code/acp-bridge/externalToolGuard'; import type { ExternalToolGuardHandler, ExternalToolGuardPrepareRequest, ExternalToolGuardPrepareResult, } from '@qwen-code/acp-bridge/bridgeOptions'; -const READ_ONLY_GIT_SUBCOMMANDS = new Set([ - 'blame', +// Git subcommands allowed even when relocated outside the session working +// directory. Limited to subcommands verified to neither write files nor +// execute programs configured by the target repository on the managed +// (non-tty) output path. `diff`/`log`/`show`/`blame` are excluded: `--output` +// writes files and textconv drivers run commands from target-repository +// config. +const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ 'cat-file', 'describe', - 'diff', 'grep', - 'log', 'ls-files', 'rev-parse', - 'show', 'status', ]); +// Git global options whose next argv entry is consumed as a value (mirrors +// core shell.ts GIT_GLOBAL_FLAGS_TAKES_VALUE). const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ - '-c', - '--config-env', + '--exec-path', + '--list-cmds', '--namespace', '--super-prefix', ]); +// `-c`/`--config-env` keys whose values git executes through a shell. A +// relocated mutation can be embedded in such a value with no relocation in +// the outer argv, so these mark a mutating invocation unresolved. +const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ + /^alias\./, + /^core\.(editor|pager|fsmonitor)$/, + /^credential\.helper$/, + /^difftool\./, + /^filter\./, + /^mergetool\./, +]; + +// Environment assignments that redirect git's repository selection (mirrors +// core shell.ts GIT_ENV_SHIFTS_REPO). +const GIT_DIR_ENV_KEYS = new Set(['GIT_COMMON_DIR', 'GIT_DIR']); +const GIT_WORK_TREE_ENV_KEYS = new Set(['GIT_INDEX_FILE', 'GIT_WORK_TREE']); + +const SHELL_WRAPPER_PROGRAMS = new Set(['bash', 'dash', 'ksh', 'sh', 'zsh']); +const SHELL_WRAPPER_VALUE_FLAGS = new Set(['-o', '-O']); + +// `-c` bundled inside short flags (`bash -lc 'cmd'`) still consumes the next +// argv entry as the payload. `-o`/`-O` take values, so either one earlier in +// the bundle consumes the rest of it and `-c` is not present. +function shellBundleRequestsCommand(flag: string): boolean { + if (!flag.startsWith('-') || flag.startsWith('--')) return false; + for (const character of flag.slice(1)) { + if (character === 'o' || character === 'O') return false; + if (character === 'c') return true; + } + return false; +} + +const ENV_CHDIR_FLAGS = new Set(['-C', '--chdir']); +const ENV_VALUE_FLAGS = new Set(['-S', '--split-string', '-u', '--unset']); +const ENV_KNOWN_FLAG_ONLY = new Set(['-', '-0', '-i', '-v']); + +// Union of core shell-utils/shell.ts value-taking sudo options. +const SUDO_VALUE_FLAGS = new Set([ + '-C', + '-D', + '-T', + '-g', + '-h', + '-p', + '-r', + '-t', + '-u', + '--chdir', + '--close-from', + '--command-timeout', + '--group', + '--host', + '--prompt', + '--role', + '--type', + '--user', +]); +const SUDO_CHDIR_FLAGS = new Set(['-D', '--chdir']); + +const TIMEOUT_VALUE_FLAGS = new Set(['-k', '-s', '--kill-after', '--signal']); + +// Pinned to ToolNames.AGENT/WORKFLOW/CREATE_SUB_SESSION/SEND_MESSAGE in +// @qwen-code/qwen-code-core. The literals keep this module free of a core +// barrel import for this one set; daemon-git-worktree-guard.test.ts asserts +// the values match so a rename cannot silently desync this set. const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ 'agent', 'workflow', @@ -39,163 +115,912 @@ const EXTERNAL_GUARD_UNSUPPORTED_TOOLS = new Set([ 'send_message', ]); +const DYNAMIC_RELOCATION_DENIAL = + 'Daemon shell guard denied a mutating Git command with a dynamic repository location.'; +const UNPARSEABLE_COMMAND_DENIAL = + 'Daemon shell guard denied a shell command that could not be parsed before execution.'; +const UNRESOLVED_TARGET_DENIAL_PREFIX = + 'Daemon shell guard denied a mutating Git command with an unresolvable repository location: '; +const OUTSIDE_TARGET_DENIAL_PREFIX = + 'Daemon shell guard denied a mutating Git command outside the session working directory: '; + +const MAX_PAYLOAD_RECURSION_DEPTH = 3; + 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; - } - } +interface GuardToken { + readonly text: string; + readonly dynamic: boolean; +} + +interface GitEnvRelocation { + readonly target: string; + readonly kind: 'cwd' | 'git-dir' | 'work-tree'; +} + +interface PrefixState { + readonly relocations: GitEnvRelocation[]; + unresolved: boolean; +} + +type GuardDenial = { allowed: false; reason: string }; + +function sanitizeDenialPath(value: string, prefix: string): string { + // Mirror containsUnsafeExternalToolGuardControlCharacter: control + // characters would convert a clean denial into an invalid guard result. + const stripped = [...value] + .filter((character) => { + const code = character.charCodeAt(0); + return ( + code >= 0x20 && + !(code >= 0x7f && code <= 0x9f) && + code !== 0x2028 && + code !== 0x2029 + ); + }) + .join(''); + const budget = EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS - prefix.length; + if (stripped.length <= budget) return stripped; + return `${stripped.slice(0, Math.max(1, budget - 1))}…`; +} + +function denyDynamicRelocation(): GuardDenial { + return { allowed: false, reason: DYNAMIC_RELOCATION_DENIAL }; } -function isWithin(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); +function denyTarget(prefix: string, target: string): GuardDenial { + return { + allowed: false, + reason: `${prefix}${sanitizeDenialPath(target, prefix)}`, + }; +} + +function isDynamicPathValue(token: GuardToken | undefined): boolean { return ( - relative === '' || - (!relative.startsWith('..') && !path.isAbsolute(relative)) + token === undefined || + token.dynamic || + token.text.includes('`') || + token.text.startsWith('~') ); } -function readGitInvocation(tokens: string[]): GitInvocation | null { - const relocations: GitInvocation['relocations'] = []; - let unresolvedRelocation = false; +function leadingEnvAssignmentKey(token: string): string | null { + const match = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(token); + return match ? match[1]! : null; +} + +function executableBaseName(token: GuardToken): string { + const base = token.text.split(/[\\/]/).pop() ?? token.text; + return base.toLowerCase().replace(/\.exe$/i, ''); +} + +const REDIRECT_OPERATORS = new Set([ + '<', + '>', + '>>', + '<<', + '<<<', + '<>', + '>&', + '<&', + '>|', + '&>', + '&>>', +]); + +function tokenizeSegment(segment: string): GuardToken[][] | null { + let parsed: ReturnType; + try { + parsed = parse(segment, (key) => `$${key}`); + } catch { + return null; + } + const runs: GuardToken[][] = [[]]; + let skipRedirectOperand = false; + for (let index = 0; index < parsed.length; index++) { + const token = parsed[index]; + if (typeof token === 'string') { + if (skipRedirectOperand) { + skipRedirectOperand = false; + continue; + } + // A `$(...)` substitution arrives as a string ending in `$` followed + // by an `(` operator. Consume the whole body as one opaque dynamic + // token so the assignment/flag it belongs to keeps its place instead + // of being severed into a separate run. + if (token.endsWith('$')) { + const next = parsed[index + 1]; + if ( + next !== null && + typeof next === 'object' && + 'op' in next && + next.op === '(' + ) { + let depth = 0; + index++; + for (; index < parsed.length; index++) { + const inner = parsed[index]; + if (inner !== null && typeof inner === 'object' && 'op' in inner) { + if (inner.op === '(') depth++; + else if (inner.op === ')') { + depth--; + if (depth === 0) break; + } + } + } + runs.at(-1)!.push({ text: token, dynamic: true }); + skipRedirectOperand = false; + continue; + } + } + runs.at(-1)!.push({ + text: token, + dynamic: token.includes('$') || token.includes('`'), + }); + continue; + } + if (token === null || typeof token !== 'object') return null; + if ('comment' in token) break; + if (!('op' in token)) return null; + const op = token.op; + skipRedirectOperand = false; + if (op === 'glob') { + // Glob expansion is resolved by the shell at runtime; the daemon + // cannot evaluate it statically. + const pattern = + 'pattern' in token && typeof token.pattern === 'string' + ? token.pattern + : ''; + runs.at(-1)!.push({ text: pattern, dynamic: true }); + continue; + } + if (op === '(' || op === ')') { + runs.push([]); + continue; + } + if (REDIRECT_OPERATORS.has(op)) { + skipRedirectOperand = true; + continue; + } + runs.push([]); + } + return runs.filter((run) => run.length > 0); +} + +function joinTokenTexts(tokens: GuardToken[]): string { + return tokens.map((token) => token.text).join(' '); +} + +function hasGitRelocationMarker(tokens: GuardToken[]): boolean { + return tokens.some((token) => { + if (token.text === '-C' || token.text.startsWith('-C')) return true; + if (/^--(?:git-dir|work-tree)(?:=|$)/.test(token.text)) return true; + const key = leadingEnvAssignmentKey(token.text); + return ( + key !== null && + (GIT_DIR_ENV_KEYS.has(key) || GIT_WORK_TREE_ENV_KEYS.has(key)) + ); + }); +} + +function recordEnvAssignment(token: GuardToken, state: PrefixState): void { + const key = leadingEnvAssignmentKey(token.text); + if (key === null) return; + if (!GIT_DIR_ENV_KEYS.has(key) && !GIT_WORK_TREE_ENV_KEYS.has(key)) return; + const value = token.text.slice(token.text.indexOf('=') + 1); + if (token.dynamic || isDynamicPathValue({ text: value, dynamic: false })) { + state.unresolved = true; + return; + } + state.relocations.push({ + target: value, + // GIT_COMMON_DIR and GIT_INDEX_FILE cannot be mapped onto a repository + // root the way `--git-dir` targets are; checking the concrete path they + // name is the conservative approximation. + kind: GIT_WORK_TREE_ENV_KEYS.has(key) ? 'work-tree' : 'git-dir', + }); +} + +function attachedChdirValue( + flag: string, + set: ReadonlySet, +): string | undefined { + for (const candidate of set) { + if (candidate.startsWith('--') && flag.startsWith(`${candidate}=`)) { + return flag.slice(candidate.length + 1); + } + if ( + candidate.length === 2 && + flag.startsWith(candidate) && + flag.length > candidate.length + ) { + return flag.slice(candidate.length); + } + } + return undefined; +} + +function recordChdirValue( + value: GuardToken | undefined, + state: PrefixState, +): void { + if (isDynamicPathValue(value)) { + state.unresolved = true; + return; + } + state.relocations.push({ target: value!.text, kind: 'cwd' }); +} + +interface WrapperScan { + next: number; + payload?: string; +} + +function consumeEnvWrapper( + run: GuardToken[], + start: number, + state: PrefixState, +): WrapperScan { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.dynamic) { + state.unresolved = true; + index++; + continue; + } + if (token.text === '--') { + index++; + break; + } + if (ENV_KNOWN_FLAG_ONLY.has(token.text)) { + index++; + continue; + } + if (ENV_CHDIR_FLAGS.has(token.text)) { + recordChdirValue(run[index + 1], state); + index += 2; + continue; + } + const attached = attachedChdirValue(token.text, ENV_CHDIR_FLAGS); + if (attached !== undefined) { + recordChdirValue({ text: attached, dynamic: false }, state); + index++; + continue; + } + if (token.text === '-S' || token.text === '--split-string') { + const payloadToken = run[index + 1]; + if (payloadToken === undefined) return { next: run.length }; + const rest = joinTokenTexts(run.slice(index + 2)); + return { + next: run.length, + payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, + }; + } + if (ENV_VALUE_FLAGS.has(token.text)) { + index += 2; + continue; + } + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + index++; + continue; + } + if (token.text.startsWith('-')) { + // Unrecognized env option before the program: fail closed rather than + // guess whether it consumes the next token. + state.unresolved = true; + index++; + continue; + } + break; + } + return { next: index }; +} + +function consumeSudoWrapper( + run: GuardToken[], + start: number, + state: PrefixState, +): WrapperScan { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.dynamic) { + state.unresolved = true; + index++; + continue; + } + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + index++; + continue; + } + if (!token.text.startsWith('-')) break; + if (SUDO_CHDIR_FLAGS.has(token.text)) { + recordChdirValue(run[index + 1], state); + index += 2; + continue; + } + const attached = attachedChdirValue(token.text, SUDO_CHDIR_FLAGS); + if (attached !== undefined) { + recordChdirValue({ text: attached, dynamic: false }, state); + index++; + continue; + } + if (SUDO_VALUE_FLAGS.has(token.text)) { + index += 2; + continue; + } + index++; + } + return { next: index }; +} + +function consumeTimeoutWrapper(run: GuardToken[], start: number): number { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (!token.text.startsWith('-')) break; + if (TIMEOUT_VALUE_FLAGS.has(token.text) && !token.text.includes('=')) { + index += 2; + continue; + } + index++; + } + // The duration operand. + if (index < run.length) index++; + return index; +} + +function shellWrapperPayload( + run: GuardToken[], + payloadToken: GuardToken | undefined, +): string | undefined { + if (payloadToken === undefined || payloadToken.dynamic) return undefined; + return payloadToken.text; +} + +function consumeShellWrapper( + run: GuardToken[], + start: number, +): string | undefined { + let index = start + 1; + while (index < run.length) { + const token = run[index]!; + if (token.text === '-c' || shellBundleRequestsCommand(token.text)) { + return shellWrapperPayload(run, run[index + 1]); + } + if (token.dynamic) return undefined; + if (token.text.startsWith('+')) { + index++; + continue; + } + if (!token.text.startsWith('-')) return undefined; + if (token.text === '--') return undefined; + index += SHELL_WRAPPER_VALUE_FLAGS.has(token.text) ? 2 : 1; + } + return undefined; +} + +type RunAnalysis = + | { kind: 'git'; tokens: GuardToken[]; state: PrefixState } + | { + kind: 'payload'; + payload: string; + state: PrefixState; + propagatesCwd: boolean; + } + | { + kind: 'cd'; + variant: 'cd' | 'popd' | 'pushd'; + target?: GuardToken; + } + | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } + | { kind: 'other'; state: PrefixState }; + +function analyzeRun(run: GuardToken[]): RunAnalysis { + const state: PrefixState = { relocations: [], unresolved: false }; + let index = 0; + while ( + index < run.length && + (run[index]!.text === '{' || run[index]!.text === '!') + ) { + index++; + } + while (index < run.length) { + const token = run[index]!; + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, state); + index++; + continue; + } + if (token.dynamic) { + return { kind: 'dynamic-program', rest: run.slice(index), state }; + } + const program = executableBaseName(token); + if (program === 'command') { + index++; + while (index < run.length && run[index]!.text.startsWith('-')) index++; + continue; + } + if (program === 'env') { + const scan = consumeEnvWrapper(run, index, state); + if (scan.payload !== undefined) { + return { + kind: 'payload', + payload: scan.payload, + state, + propagatesCwd: false, + }; + } + index = scan.next; + continue; + } + if (program === 'sudo') { + index = consumeSudoWrapper(run, index, state).next; + continue; + } + if (program === 'timeout') { + index = consumeTimeoutWrapper(run, index); + continue; + } + if (program === 'eval') { + const payloadTokens = run.slice(index + 1); + if (payloadTokens.some((payloadToken) => payloadToken.dynamic)) { + return { kind: 'dynamic-program', rest: payloadTokens, state }; + } + return { + kind: 'payload', + payload: joinTokenTexts(payloadTokens), + state, + // `eval` runs in the current shell, so a `cd` inside the payload + // relocates subsequent commands in this run's scope. + propagatesCwd: true, + }; + } + if (SHELL_WRAPPER_PROGRAMS.has(program)) { + const payload = consumeShellWrapper(run, index); + if (payload === undefined) return { kind: 'other', state }; + return { kind: 'payload', payload, state, propagatesCwd: false }; + } + if (program === 'nohup' || program === 'exec') { + index++; + continue; + } + if (program === 'git') { + return { kind: 'git', tokens: run.slice(index), state }; + } + if (program === 'cd' || program === 'pushd' || program === 'popd') { + return { kind: 'cd', variant: program, target: run[index + 1] }; + } + return { kind: 'other', state }; + } + return { kind: 'other', state }; +} + +interface GitInvocation { + readonly cwdTargets: GuardToken[]; + readonly gitDirTargets: GuardToken[]; + readonly workTreeTargets: GuardToken[]; + readonly subcommand?: string; + readonly unresolved: boolean; + readonly dangerousConfig: boolean; + readonly hasOutputFlag: boolean; +} + +function readGitInvocation(tokens: GuardToken[]): GitInvocation { + const cwdTargets: GuardToken[] = []; + const gitDirTargets: GuardToken[] = []; + const workTreeTargets: GuardToken[] = []; + let subcommand: string | undefined; + let unresolved = false; + let dangerousConfig = false; + + const recordConfigAssignment = (value: string): void => { + const separator = value.indexOf('='); + const key = separator >= 0 ? value.slice(0, separator) : value; + const assignment = separator >= 0 ? value.slice(separator + 1) : ''; + if ( + GIT_COMMAND_CONFIG_KEY_PATTERNS.some((pattern) => pattern.test(key)) || + assignment.trimStart().startsWith('!') + ) { + dangerousConfig = true; + } + }; + const pushRelocation = ( + kind: 'cwd' | 'git-dir' | 'work-tree', + value: GuardToken | undefined, + emptyIsNoop: boolean, + ): boolean => { + if (value === undefined) { + unresolved = true; + return false; + } + if (value.text === '' && !value.dynamic) { + if (emptyIsNoop) return true; + unresolved = true; + return false; + } + if (isDynamicPathValue(value)) { + unresolved = true; + return true; + } + if (kind === 'cwd') cwdTargets.push(value); + else if (kind === 'git-dir') gitDirTargets.push(value); + else workTreeTargets.push(value); + return true; + }; + 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', - }); - } + if (token.dynamic) { + unresolved = true; + index++; + continue; + } + if (token.text === '-C') { + // Git treats an empty `-C` value as a no-op chdir. + if (!pushRelocation('cwd', tokens[index + 1], true)) break; 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' }); + if (token.text === '--git-dir' || token.text === '--work-tree') { + const kind = token.text === '--git-dir' ? 'git-dir' : 'work-tree'; + if (!pushRelocation(kind, tokens[index + 1], false)) break; + index += 2; + continue; + } + if (token.text.length > 2 && token.text.startsWith('-C')) { + if ( + !pushRelocation( + 'cwd', + { text: token.text.slice(2), dynamic: false }, + false, + ) + ) { + break; } 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', - }); + if ( + token.text.startsWith('--git-dir=') || + token.text.startsWith('--work-tree=') + ) { + const kind = token.text.startsWith('--git-dir=') + ? 'git-dir' + : 'work-tree'; + const value = token.text.slice(token.text.indexOf('=') + 1); + if (!pushRelocation(kind, { text: value, dynamic: false }, false)) { + break; } index++; continue; } - if (GIT_GLOBAL_OPTIONS_WITH_VALUES.has(token)) { + if (token.text === '-c' || token.text === '--config-env') { + const value = tokens[index + 1]; + if (value === undefined) break; + if (value.dynamic) dangerousConfig = true; + else recordConfigAssignment(value.text); index += 2; continue; } + if (token.text.startsWith('--config-env=')) { + recordConfigAssignment(token.text.slice('--config-env='.length)); + index++; + continue; + } if ( - token.startsWith('--config-env=') || - token.startsWith('--exec-path=') || - token.startsWith('--namespace=') || - token.startsWith('--super-prefix=') + token.text.length > 2 && + token.text.startsWith('-c') && + !token.text.startsWith('--') ) { + recordConfigAssignment(token.text.slice(2)); index++; continue; } - if (token.startsWith('-')) { + if (GIT_GLOBAL_OPTIONS_WITH_VALUES.has(token.text)) { + if (tokens[index + 1] === undefined) break; + index += 2; + continue; + } + if (token.text.startsWith('-')) { index++; continue; } - return relocations.length > 0 || unresolvedRelocation - ? { relocations, subcommand: token, unresolvedRelocation } - : null; + subcommand = token.text; + break; + } + + const hasOutputFlag = tokens.some( + (token) => token.text === '--output' || token.text.startsWith('--output='), + ); + return { + cwdTargets, + gitDirTargets, + workTreeTargets, + subcommand, + unresolved, + dangerousConfig, + hasOutputFlag, + }; +} + +/** + * Resolve a `--git-dir`/`GIT_DIR` target to the repository git operates on, + * following git's own indirections: a `.git` gitfile redirect (`gitdir:` + * line) and per-worktree administrative directories (their `gitdir` file + * points at the linked worktree checkout). Canonicalization happens BEFORE + * any basename handling so a symlink named `.git` resolves to its real + * target. Throws when an indirection cannot be resolved. + */ +async function resolveGitDirRepository( + canonicalGitDir: string, +): Promise { + let current = canonicalGitDir; + for (let depth = 0; depth < 3; depth++) { + const stats = await stat(current); + if (stats.isFile()) { + const [firstLine] = (await readFile(current, 'utf8')).split(/\r?\n/); + const match = /^gitdir:\s*(.+)$/.exec(firstLine ?? ''); + if (!match) throw new Error('unrecognized gitfile'); + current = path.resolve(path.dirname(current), match[1]!.trim()); + continue; + } + if (path.basename(current) === '.git') { + return path.dirname(current); + } + if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(current)) { + const worktreeGitPointer = ( + await readFile(path.join(current, 'gitdir'), 'utf8') + ).trim(); + if (!worktreeGitPointer) throw new Error('empty worktree gitdir file'); + return path.dirname(path.resolve(current, worktreeGitPointer)); + } + return current; } - return relocations.length > 0 || unresolvedRelocation - ? { relocations, unresolvedRelocation } - : null; + throw new Error('gitdir indirection too deep'); } -let shellQuotePromise: Promise | undefined; +interface GuardEvaluationContext { + readonly canonicalEffectiveCwd: string; + readonly ambientRelocations: readonly GitEnvRelocation[]; + readonly ambientUnresolved: boolean; +} -async function readCommandSegments(command: string): Promise { - const segments: string[][] = []; - try { - shellQuotePromise ??= import('shell-quote'); - const { parse } = await shellQuotePromise; - 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 []; - } +async function evaluateGitInvocation( + invocation: GitInvocation, + state: PrefixState, + basisCwd: string | undefined, + entryCwd: string | undefined, + context: GuardEvaluationContext, +): Promise { + if ( + RELOCATED_READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') && + !invocation.hasOutputFlag + ) { + return undefined; + } + if ( + invocation.unresolved || + invocation.dangerousConfig || + state.unresolved || + context.ambientUnresolved + ) { + return denyDynamicRelocation(); + } + + const cwdRelocations: GitEnvRelocation[] = []; + const repositoryRelocations: GitEnvRelocation[] = []; + for (const relocation of [ + ...context.ambientRelocations, + ...state.relocations, + ]) { + if (relocation.kind === 'cwd') cwdRelocations.push(relocation); + else repositoryRelocations.push(relocation); + } + for (const target of invocation.cwdTargets) { + cwdRelocations.push({ target: target.text, kind: 'cwd' }); + } + for (const target of invocation.gitDirTargets) { + repositoryRelocations.push({ target: target.text, kind: 'git-dir' }); + } + for (const target of invocation.workTreeTargets) { + repositoryRelocations.push({ target: target.text, kind: 'work-tree' }); + } + + // Ambient relocations recorded here are git-level relocations from an + // enclosing wrapper (e.g. `GIT_DIR=… sh -c '…'`); they make the payload + // invocation relocated even when the payload itself carries no flags. + const relocated = + basisCwd === undefined || + basisCwd !== entryCwd || + cwdRelocations.length > 0 || + repositoryRelocations.length > 0; + if (!relocated) return undefined; + + let gitCwd = basisCwd; + for (const relocation of cwdRelocations) { + if (path.isAbsolute(relocation.target)) { + gitCwd = relocation.target; + continue; + } + if (gitCwd === undefined) break; + gitCwd = path.resolve(gitCwd, relocation.target); + } + if (gitCwd === undefined) { + return denyDynamicRelocation(); + } + + // Git applies `-C` during option parsing and resolves relative + // `--git-dir`/`--work-tree` against the post-`-C` cwd, so every relative + // target resolves against the final cwd regardless of argv order. + const checkedTargets: Array<{ + target: string; + kind: 'cwd' | 'git-dir' | 'work-tree'; + }> = []; + for (const relocation of repositoryRelocations) { + checkedTargets.push({ + target: path.isAbsolute(relocation.target) + ? relocation.target + : path.resolve(gitCwd, relocation.target), + kind: relocation.kind, + }); + } + if ( + basisCwd === undefined || + basisCwd !== entryCwd || + cwdRelocations.length > 0 + ) { + checkedTargets.push({ target: gitCwd, kind: 'cwd' }); + } + + for (const { target, kind } of checkedTargets) { + const canonicalTarget = realpathNearestExisting(target); + let repositoryTarget: string; + if (kind === 'git-dir') { + try { + repositoryTarget = await resolveGitDirRepository(canonicalTarget); + } catch { + // Missing or unreadable indirection: containment cannot be proven + // before execution. + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalTarget); + } + } else { + try { + // A target that does not fully exist at decision time may still be + // created as an outward symlink before git runs. + await realpath(canonicalTarget); + repositoryTarget = canonicalTarget; + } catch { + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalTarget); } } - return segments.filter((segment) => segment.length > 0); - } catch { - return []; + repositoryTarget = realpathNearestExisting(repositoryTarget); + if (!isWithinRoot(repositoryTarget, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, repositoryTarget); + } } + return undefined; } -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++; +interface CommandEvaluation { + readonly denial?: GuardDenial; + readonly cwdAfter: string | undefined; +} + +async function evaluateCommandWithCwd( + command: string, + entryCwd: string | undefined, + context: GuardEvaluationContext, + depth: number, +): Promise { + let trackedCwd = entryCwd; + for (const segment of splitCommands(command)) { + const runs = tokenizeSegment(segment); + if (runs === null) { + return { + denial: { allowed: false, reason: UNPARSEABLE_COMMAND_DENIAL }, + cwdAfter: trackedCwd, + }; + } + for (const run of runs) { + const analysis = analyzeRun(run); + switch (analysis.kind) { + case 'cd': { + const target = analysis.target; + if ( + analysis.variant === 'popd' || + target === undefined || + (analysis.variant === 'pushd' && /^[-+]/.test(target.text)) + ) { + // `popd`, bare `cd` ($HOME), and dir-stack rotations land the + // shell somewhere the daemon cannot resolve statically. + trackedCwd = undefined; + break; + } + if (target.text === '-' || isDynamicPathValue(target)) { + trackedCwd = undefined; + break; + } + if (path.isAbsolute(target.text)) { + trackedCwd = target.text; + break; + } + trackedCwd = + trackedCwd === undefined + ? undefined + : path.resolve(trackedCwd, target.text); + break; + } + case 'payload': { + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + const ambient: GuardEvaluationContext = { + canonicalEffectiveCwd: context.canonicalEffectiveCwd, + ambientRelocations: [ + ...context.ambientRelocations, + ...analysis.state.relocations, + ], + ambientUnresolved: + context.ambientUnresolved || analysis.state.unresolved, + }; + const nested = await evaluateCommandWithCwd( + analysis.payload, + trackedCwd, + ambient, + depth + 1, + ); + if (nested.denial) { + return { denial: nested.denial, cwdAfter: trackedCwd }; + } + if (analysis.propagatesCwd) { + trackedCwd = nested.cwdAfter; + } + break; + } + case 'git': { + const invocation = readGitInvocation(analysis.tokens); + const denial = await evaluateGitInvocation( + invocation, + analysis.state, + trackedCwd, + entryCwd, + context, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + case 'dynamic-program': { + if ( + analysis.state.unresolved || + analysis.state.relocations.length > 0 || + context.ambientUnresolved || + context.ambientRelocations.length > 0 || + hasGitRelocationMarker(analysis.rest) + ) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + break; + } + case 'other': + break; + default: { + const exhaustive: never = analysis; + void exhaustive; + break; + } + } } } - return tokens[index] === 'git' ? index : -1; + return { cwdAfter: trackedCwd }; } async function evaluateBuiltInGuard( @@ -210,52 +1035,19 @@ async function evaluateBuiltInGuard( typeof startDirectoryValue === 'string' ? startDirectoryValue : request.effectiveCwd; - const canonicalEffectiveCwd = await canonicalize(request.effectiveCwd); - - for (const segment of await 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.', - }; - } + const canonicalEffectiveCwd = realpathNearestExisting(request.effectiveCwd); - 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 }; + const { denial } = await evaluateCommandWithCwd( + command, + startDirectory, + { + canonicalEffectiveCwd, + ambientRelocations: [], + ambientUnresolved: false, + }, + 0, + ); + return denial ?? { allowed: true }; } export function createDaemonToolGuard( diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 165976d48fa..db36524c366 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3546,7 +3546,33 @@ describe('runQwenServe runtime startup failures', () => { QWEN_SERVE_CDP_TUNNEL_OVER_WS: '1', QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD: 'required-v1', }); - expect(bridgeOptions?.externalToolGuard).toEqual(expect.any(Function)); + // No external provider is configured in this test: the child must see + // the guard plumbing marker but NOT the provider-attached marker. + expect(bridgeOptions?.childEnvOverrides).toHaveProperty( + 'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER', + undefined, + ); + expect(createBridge.mock.calls.length).toBeGreaterThan(0); + for (const call of createBridge.mock.calls) { + const options = call[0] as { externalToolGuard?: unknown }; + expect(options.externalToolGuard).toEqual(expect.any(Function)); + } + const daemonGuard = bridgeOptions?.externalToolGuard as ( + request: Record, + ) => Promise<{ allowed: boolean; reason?: string }>; + await expect( + daemonGuard({ + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { + command: `git -C ${path.join(os.tmpdir(), 'outside-repo')} reset --hard`, + }, + workspaceCwd: tmpDir, + effectiveCwd: tmpDir, + }), + ).resolves.toMatchObject({ allowed: false }); } 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 f7afdaeab0e..fa3d6ebfedc 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -92,9 +92,11 @@ import { SERVE_CAPABILITY_REGISTRY, } from './capabilities.js'; import { + EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, EXTERNAL_TOOL_GUARD_TOKEN_ENV, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, + PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import { CAPABILITIES_SCHEMA_VERSION, @@ -2809,6 +2811,9 @@ async function runQwenServeImpl( QWEN_SERVE_MCP_BUDGET_MODE: opts.mcpBudgetMode, QWEN_SERVE_CDP_TUNNEL_OVER_WS: opts.cdpTunnelOverWs ? '1' : undefined, [PRIVATE_EXTERNAL_TOOL_GUARD_ENV]: EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, + [PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV]: externalToolGuardHandler + ? EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE + : undefined, }; const cliVersionPromise = getCliVersion(); From b1b76060528f81243cd768091e073aaaca3fa3cf Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 7 Aug 2026 19:23:51 +0000 Subject: [PATCH 04/45] fix(serve): keep daemon Git guard out of serve fast-path closure Co-authored-by: Qwen-Coder --- packages/cli/src/serve/run-qwen-serve.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index fa3d6ebfedc..055c0689cdb 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -135,7 +135,6 @@ 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'; @@ -2800,6 +2799,11 @@ async function runQwenServeImpl( 'qwen serve: required external tool guard handshake succeeded.', ); } + // Dynamic-imported (not at module scope) so the guard's core helper + // imports stay out of the serve fast-path bundle closure. + const { createDaemonToolGuard } = await import( + './daemon-git-worktree-guard.js' + ); const daemonToolGuardHandler = createDaemonToolGuard( externalToolGuardHandler, ); From 83428be2ab8ec8857db04f39446e67aeec59b2ff Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 8 Aug 2026 03:13:19 +0000 Subject: [PATCH 05/45] fix(serve): close re-reviewed daemon Git guard bypasses and subagent regression Address the re-review at b1b7606: keep the outermost entry cwd as the containment basis inside shell wrappers, fail closed on unrecognized programs that still reference a relocated Git command, skip leading shell keywords, deny undecidable and fused `-c` payloads, inspect command-executing `-c` config before the read-only allowance, drop `grep`/`status` from the relocated read-only set, validate the model-supplied `directory` against the effective working directory, and stop modelling `--exec-path`/`--list-cmds` as value-taking. Context-less shell paths (subagents, cron turns, background notifications, resumed background agents) previously failed closed under the now-unconditional managed guard: fall back to the scheduler-owned session id and validate those requests by session ownership, while external-provider consultation still requires a prompt binding. Move the guard's canonicalization off the daemon event loop with a promise-based realpathNearestExisting, drop the unread workspaceCwd request field, and restore the top-level guard import in run-qwen-serve. Co-authored-by: Qwen-Coder --- docs/design/daemon-git-worktree-guard.md | 82 ++++-- packages/acp-bridge/src/bridgeClient.test.ts | 67 ++++- packages/acp-bridge/src/bridgeClient.ts | 23 +- packages/acp-bridge/src/bridgeOptions.ts | 11 +- .../cli/src/acp-integration/acpAgent.test.ts | 44 +++ packages/cli/src/acp-integration/acpAgent.ts | 17 +- .../serve/daemon-git-worktree-guard.test.ts | 269 ++++++++++++++++-- .../src/serve/daemon-git-worktree-guard.ts | 217 ++++++++++---- packages/cli/src/serve/run-qwen-serve.test.ts | 1 - packages/cli/src/serve/run-qwen-serve.ts | 6 +- .../core/src/core/coreToolScheduler.test.ts | 2 + packages/core/src/core/coreToolScheduler.ts | 1 + .../core/src/core/tool-invocation-guard.ts | 7 + .../core/src/followup/speculation.test.ts | 4 + packages/core/src/followup/speculation.ts | 1 + packages/core/src/utils/paths.test.ts | 41 +++ packages/core/src/utils/paths.ts | 63 ++++ 17 files changed, 727 insertions(+), 129 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index fdc57ef8fd5..dd798b015ac 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -15,10 +15,11 @@ 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. +The daemon enables its managed tool guard for every ACP child. The host owns +the session's effective working directory 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 @@ -39,17 +40,26 @@ changed by literal forms of: Wrapper prefixes are unwrapped before Git detection: leading env assignments, `command`, `env` (with its value-taking flags), `sudo` (with its value-taking flags), `nohup`, `exec`, `timeout `, `sh|bash|dash|zsh|ksh -c` -payloads (analyzed recursively), `eval` payloads (analyzed recursively, with -cwd changes propagated because `eval` runs in the current shell), -path-qualified Git binaries by basename, and leading `{`/`!` shell syntax. -A segment whose program token cannot be classified (shell expansions) fails -closed when the segment also carries a Git relocation marker or a recorded -relocation. +payloads (analyzed recursively, keeping the outermost run's entry cwd as the +containment basis so a preceding `cd` cannot disappear inside the wrapper), +`eval` payloads (analyzed recursively, with cwd changes propagated because +`eval` runs in the current shell), path-qualified Git binaries by basename, +and leading shell keywords and reserved words (`{`, `}`, `!`, `if`, `then`, +`else`, `elif`, `fi`, `for`, `do`, `done`, `while`, `until`, `in`, `case`, +`esac`, `time`, `coproc`), which can lead a split segment without changing +what executes. A segment whose program token cannot be classified fails +closed when the segment still references Git and carries a relocation marker +(token-level or inside a quoted payload), a recorded relocation, or an +unresolved prefix. A `-c` payload that is dynamic (`sh -c "$CMD"`) or fused +into the flag token (`bash -c'cmd'`, read from the same token) is analyzed +after extraction; an undecidable payload is denied rather than allowed. 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. A model-supplied `directory` is itself canonicalized and +checked against the effective working directory before it is trusted as the +containment basis. The bridge supplies the current directory 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. Git applies `-C` @@ -65,17 +75,20 @@ hold: 2. its Git subcommand is mutating or cannot be classified as read-only. Relocated commands whose subcommand is in a small verified read-only set -(`status`, `rev-parse`, `ls-files`, `grep`, `describe`, `cat-file`) remain -allowed. `diff`, `log`, `show`, and `blame` are excluded from that set: -`--output` writes files, and textconv-style drivers execute programs -configured by the target repository. Any `--output` flag demotes an -invocation. Commands with no recognized relocation retain existing behavior. +(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed. `diff`, +`log`, `show`, and `blame` are excluded from that set: `--output` writes +files, and textconv-style drivers execute programs configured by the target +repository. `grep` takes the same `--textconv` path, and `status` refreshes +the target index and runs the target repository's `core.fsmonitor`, so +neither is read-only here. Any `--output` flag demotes an invocation. Commands with no recognized relocation retain existing behavior. Dynamic relocation targets (`$` expansions, backticks, leading `~`, globs) and command-executing `-c`/`--config-env` assignments (`alias.*`, `core.editor`, `core.pager`, `credential.helper`, `filter.*`, `difftool.*`, -`mergetool.*`, `core.fsmonitor`, or values starting with `!`) are denied for -mutating or unknown subcommands because the daemon cannot prove that the -target remains inside the effective working directory. +`mergetool.*`, `core.fsmonitor`, or values starting with `!`) are denied +regardless of the subcommand — the check runs before the read-only allowance +because even `status` executes a target-repo-configured `core.fsmonitor` — +because the daemon cannot prove that the target remains inside the effective +working directory. `--git-dir` is evaluated by the repository git operates on, with canonicalization before basename handling: a target whose canonical form ends @@ -87,8 +100,8 @@ linked worktree checkout. Unresolvable indirections fail closed. ## 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. Unparseable commands, dangling +trusted effective working directory, policy exceptions, and malformed +external-provider responses fail closed before execution. Unparseable commands, dangling relocation options, relocation targets that do not fully exist at decision time (a missing target can still become an outward symlink before git runs), and unreadable Git indirections are denied for mutating or unclassifiable @@ -101,11 +114,26 @@ built-in policy needs it. The child-side v1 restrictions (`/fork` and agent-backed workspace memory remember/dream) key on the external provider being attached, not on the plumbing's mere presence: under the built-in guard alone, hidden-agent tool calls traverse the same managed guard and are -inspected by the same daemon-side policy. Without a provider the child also -resolves every non-shell tool call locally (the built-in policy allows them -structurally) instead of paying a child-daemon-child round trip per call; -`run_shell_command` always makes the round trip. With a provider attached -every call still makes it. +inspected by the same daemon-side policy. Subagent reasoning loops, cron +turns, background notifications, and resumed background agents run without an +invocation context by design; their shell calls fall back to the +scheduler-owned session identity and are validated by session ownership +alone, because the built-in policy needs the effective working directory, +not a live prompt. Consulting the external provider always requires a prompt +binding, so a prompt-less request with a provider attached fails closed. +Without a provider the child also resolves every non-shell tool call locally +(the built-in policy allows them structurally) instead of paying a +child-daemon-child round trip per call; `run_shell_command` always makes the +round trip. With a provider attached every prompt-bound call still makes it. + +## Limitations + +The guard is a containment control against mis-targeted Git invocations +expressed in the literal forms above. It is not a sandbox against a +prompt-injected agent: script-file contents are not read, variable values are +not tracked across commands, and program words outside the unwrapped set are +handled by failing closed on Git-shaped runs rather than by modelling their +execution semantics. ## Non-goals diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 1c4b9686003..c73d26bf7f2 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -279,12 +279,11 @@ describe('BridgeClient — managed external tool guard', () => { toolCallId: 'call-1', toolName: 'write_file', arguments: { path: 'README.md' }, - workspaceCwd: '/workspace', effectiveCwd: '/workspace/worktree', }); }); - it('ignores forged workspace fields in the child payload', async () => { + it('ignores a forged effective directory in the child payload', async () => { const handler = vi.fn().mockResolvedValue({ allowed: true, }); @@ -314,7 +313,6 @@ describe('BridgeClient — managed external tool guard', () => { toolCallId: 'call-1', toolName: 'write_file', arguments: { path: 'README.md' }, - workspaceCwd: '/forged/workspace', effectiveCwd: '/forged/effective', }), ).resolves.toEqual({ allowed: true }); @@ -324,9 +322,72 @@ describe('BridgeClient — managed external tool guard', () => { toolCallId: 'call-1', toolName: 'write_file', arguments: { path: 'README.md' }, + effectiveCwd: '/workspace/worktree', + }); + }); + + it('accepts a prompt-less shell check validated by session ownership', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + const entry: { + sessionId: string; + workspaceCwd: string; + effectiveCwd: string; + promptActive: boolean; + activePromptId?: string; + } = { + sessionId: 'session-1', workspaceCwd: '/workspace', effectiveCwd: '/workspace/worktree', + promptActive: false, + }; + const client = makeClient(undefined, { + resolveEntry: (sessionId) => + sessionId === entry.sessionId ? entry : undefined, + handler, }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + }), + ).resolves.toEqual({ allowed: true }); + expect(handler).toHaveBeenCalledWith({ + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + effectiveCwd: '/workspace/worktree', + }); + }); + + it('rejects an empty prompt id in a guard request', async () => { + const handler = vi.fn().mockResolvedValue({ + allowed: true, + }); + const client = makeClient(undefined, { + resolveEntry: () => ({ + sessionId: 'session-1', + promptActive: true, + activePromptId: 'prompt-1', + }), + handler, + }); + + await expect( + client.extMethod(SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { + sessionId: 'session-1', + promptId: '', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: {}, + }), + ).rejects.toThrow('Invalid external tool guard request'); + expect(handler).not.toHaveBeenCalled(); }); it('rejects a stale prompt without contacting the host', async () => { diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 3f54d856d51..9af8a113114 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -1215,8 +1215,8 @@ export class BridgeClient implements Client { if ( typeof sessionId !== 'string' || sessionId.length === 0 || - typeof promptId !== 'string' || - promptId.length === 0 || + (promptId !== undefined && + (typeof promptId !== 'string' || promptId.length === 0)) || typeof toolCallId !== 'string' || toolCallId.length === 0 || typeof toolName !== 'string' || @@ -1228,6 +1228,11 @@ export class BridgeClient implements Client { 'Invalid external tool guard request', ); } + // Context-less shell checks (subagents, cron turns, resumed background + // agents) carry no prompt binding; they are validated by session + // ownership alone. The host handler decides whether its policy can run + // without a live prompt. + const promptScoped = promptId !== undefined; if (!this.ownsSession(sessionId)) { throw RequestError.invalidParams( undefined, @@ -1235,7 +1240,11 @@ export class BridgeClient implements Client { ); } const entry = this.resolveEntry(sessionId); - if (!entry || !entry.promptActive || entry.activePromptId !== promptId) { + if ( + !entry || + (promptScoped && + (!entry.promptActive || entry.activePromptId !== promptId)) + ) { throw RequestError.invalidParams( undefined, 'External tool guard prompt is not the active prompt', @@ -1243,19 +1252,19 @@ export class BridgeClient implements Client { } const decision: unknown = await this.externalToolGuard({ sessionId: entry.sessionId, - promptId: entry.activePromptId, + ...(promptScoped ? { promptId } : {}), toolCallId, toolName, arguments: args, - workspaceCwd: entry.workspaceCwd, effectiveCwd: entry.effectiveCwd, }); const currentEntry = this.resolveEntry(sessionId); if ( !this.ownsSession(sessionId) || currentEntry !== entry || - !currentEntry.promptActive || - currentEntry.activePromptId !== promptId + (promptScoped && + (!currentEntry.promptActive || + currentEntry.activePromptId !== promptId)) ) { throw RequestError.invalidParams( undefined, diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index af3f15b6192..d8bc7159dc3 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -73,12 +73,17 @@ export type BridgeSessionLifecycle = ( */ export interface ExternalToolGuardPrepareRequest { readonly sessionId: string; - readonly promptId: string; + /** + * Runtime-owned active-prompt binding. Absent for context-less shell + * checks: subagent reasoning loops, cron turns, background notifications, + * and resumed background agents run without an invocation context by + * design. A host policy that requires a live prompt must fail closed when + * the binding is missing. + */ + readonly promptId?: string; 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; } diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 71cd5b20270..e8960ef71ea 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -18367,6 +18367,50 @@ describe('createManagedExternalToolGuard', () => { }); }); + it('routes context-less shell calls with the scheduler session identity', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + sessionId: 'session-9', + }), + ).resolves.toEqual({ allowed: true }); + + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-9', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'pwd' }, + }, + ); + }); + + it('fails closed when neither invocation context nor session id exists', async () => { + const extMethod = vi.fn(); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'pwd' }, + signal: new AbortController().signal, + }), + ).rejects.toThrow('requires a session identity'); + expect(extMethod).not.toHaveBeenCalled(); + }); + it('fails closed without a managed invocation context', async () => { const extMethod = vi.fn(); const guard = createManagedExternalToolGuard( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index b5ab1d85698..ff20cf24289 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2814,11 +2814,22 @@ export function createManagedExternalToolGuard( return { allowed: true }; } const invocation = context.invocationContext; - if (!invocation) { + if (!invocation && options.externalProviderAttached) { throw new Error( 'Managed external tool guard requires a runtime invocation context.', ); } + // Subagent reasoning loops, cron turns, background notifications, and + // resumed background agents run without an invocation context by design. + // Under the built-in policy alone the daemon only needs the session + // identity, so fall back to the scheduler-owned session id and skip the + // prompt binding instead of denying every shell call those paths make. + const sessionId = invocation?.sessionId ?? context.sessionId; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error( + 'Managed external tool guard requires a session identity.', + ); + } if (context.signal.aborted) { throw new DOMException('Tool invocation aborted', 'AbortError'); } @@ -2839,8 +2850,8 @@ export function createManagedExternalToolGuard( connection.extMethod( SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, { - sessionId: invocation.sessionId, - promptId: invocation.promptId, + sessionId, + ...(invocation ? { promptId: invocation.promptId } : {}), toolCallId: context.callId, toolName: context.toolName, arguments: context.args, diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index b1939b4f7d4..082c2534c82 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -14,8 +14,7 @@ import type { ExternalToolGuardPrepareRequest } from '@qwen-code/acp-bridge/brid import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-')); -const workspaceCwd = path.join(temporaryRoot, 'workspace'); -const effectiveCwd = path.join(workspaceCwd, 'worktree'); +const effectiveCwd = path.join(temporaryRoot, 'workspace', 'worktree'); const insideNested = path.join(effectiveCwd, 'nested'); const outsideRepo = path.join(temporaryRoot, 'outside', 'repo'); mkdirSync(path.join(outsideRepo, '.git'), { recursive: true }); @@ -31,7 +30,6 @@ function request( toolCallId: 'call-1', toolName: 'run_shell_command', arguments: { command, ...extraArguments }, - workspaceCwd, effectiveCwd, } as ExternalToolGuardPrepareRequest; } @@ -49,6 +47,10 @@ describe('createDaemonToolGuard', () => { () => `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`, + // `grep` runs the target repo's diff..textconv programs and + // `status` refreshes the target index + runs its core.fsmonitor. + () => `git -C ${outsideRepo} grep --textconv pattern`, + () => `git -C ${outsideRepo} status --porcelain`, ])('denies relocated mutating Git command %#', async (buildCommand) => { const guard = createDaemonToolGuard(); @@ -62,7 +64,7 @@ describe('createDaemonToolGuard', () => { const guard = createDaemonToolGuard(); await expect( - guard(request(`git -C ${outsideRepo} status --short`)), + guard(request(`git -C ${outsideRepo} rev-parse HEAD`)), ).resolves.toEqual({ allowed: true }); }); @@ -234,6 +236,129 @@ describe('createDaemonToolGuard', () => { }, ); + it.each([ + () => `if true; then git -C ${outsideRepo} reset --hard; fi`, + () => `if true; then cd ${outsideRepo} && git reset --hard; fi`, + () => `for i in 1; do git -C ${outsideRepo} reset --hard; done`, + () => `while true; do git -C ${outsideRepo} reset --hard; break; done`, + () => `until false; do git -C ${outsideRepo} reset --hard; done`, + () => + `if false; then pwd; elif true; then git -C ${outsideRepo} reset --hard; fi`, + () => `time git -C ${outsideRepo} reset --hard`, + () => `coproc git -C ${outsideRepo} reset --hard`, + ])( + 'denies relocated mutations hidden behind shell keywords %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `sh -c "$(echo git -C ${outsideRepo} reset --hard)"`, + () => 'bash -c "$CMD"', + () => `eval "$(echo git -C ${outsideRepo} reset --hard)"`, + ])('fails closed on undecidable shell payloads %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('could not be resolved'), + }); + }); + + it.each([ + () => `bash -c'git -C ${outsideRepo} reset --hard'`, + () => `bash -lc'git -C ${outsideRepo} reset --hard'`, + ])( + 'denies relocated mutations fused into the -c flag token %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + () => `cd ${outsideRepo} && sh -c 'git reset --hard'`, + () => `cd ${outsideRepo} && bash -c 'git clean -fd'`, + () => `cd ${outsideRepo} && eval 'git reset --hard'`, + () => `cd ${outsideRepo}; sh -c 'git reset --hard'`, + () => `cd ${outsideRepo} && sh -c 'cd nested && git reset --hard'`, + ])( + 'keeps the entry cwd as the containment basis inside shell wrappers %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('still allows wrapper payloads that stay inside the entry cwd', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`cd ${effectiveCwd} && sh -c 'git reset --hard'`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + () => `git -c core.fsmonitor=/tmp/evil.sh -C ${outsideRepo} status`, + () => `git -c alias.x='!evil' -C ${outsideRepo} status`, + ])( + 'inspects command-executing -c config even for read-only subcommands %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); + }, + ); + + it.each([ + () => `git --exec-path -C ${outsideRepo} reset --hard`, + () => `git --list-cmds -C ${outsideRepo} reset --hard`, + ])( + 'does not let --exec-path/--list-cmds swallow the relocation token %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('denies a model-supplied directory outside the effective working directory', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git reset --hard', { directory: outsideRepo })), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + await expect( + guard( + request('git reset --hard', { + directory: path.relative(effectiveCwd, outsideRepo), + }), + ), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard(request('git reset --hard', { directory: insideNested })), + ).resolves.toEqual({ allowed: true }); + }); + it('keeps subshell cwd shifts from leaking into later commands', async () => { const guard = createDaemonToolGuard(); @@ -374,7 +499,6 @@ it -C ${outsideRepo} reset --hard`, await expect( guard({ ...request('git -C linked-outside/missing reset --hard'), - workspaceCwd: localEffectiveCwd, effectiveCwd: localEffectiveCwd, }), ).resolves.toMatchObject({ allowed: false }); @@ -394,18 +518,31 @@ it -C ${outsideRepo} reset --hard`, }); it('follows gitfile redirects before the containment check', async () => { - const gitfilePath = path.join(insideNested, '.git'); - await writeFile(gitfilePath, `gitdir: ${path.join(outsideRepo, '.git')}\n`); + // Per-test fixture: the redirect file persists for the rest of the run + // and would change how later tests resolve targets under a shared basis. + const localEffectiveCwd = path.join(temporaryRoot, 'gitfile-cwd'); + const localNested = path.join(localEffectiveCwd, 'nested'); + await mkdir(localNested, { recursive: true }); + await writeFile( + path.join(localNested, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + const localRequest = ( + command: string, + ): ExternalToolGuardPrepareRequest => ({ + ...request(command), + effectiveCwd: localEffectiveCwd, + }); const guard = createDaemonToolGuard(); await expect( - guard(request('git --git-dir=nested/.git branch -D topic')), + guard(localRequest('git --git-dir=nested/.git branch -D topic')), ).resolves.toMatchObject({ allowed: false, reason: expect.stringContaining(outsideRepo), }); await expect( - guard(request(`GIT_DIR=nested/.git sh -c 'git reset --hard'`)), + guard(localRequest(`GIT_DIR=nested/.git sh -c 'git reset --hard'`)), ).resolves.toMatchObject({ allowed: false, reason: expect.stringContaining(outsideRepo), @@ -413,13 +550,23 @@ it -C ${outsideRepo} reset --hard`, }); it('canonicalizes a symlink named .git before stripping the basename', async () => { - const linkDir = path.join(insideNested, 'd'); - await mkdir(linkDir, { recursive: true }); - await symlink(path.join(outsideRepo, '.git'), path.join(linkDir, '.git')); + const localEffectiveCwd = path.join(temporaryRoot, 'symgit-cwd'); + const localNestedD = path.join(localEffectiveCwd, 'nested', 'd'); + await mkdir(localNestedD, { recursive: true }); + await symlink( + path.join(outsideRepo, '.git'), + path.join(localNestedD, '.git'), + ); + const localRequest = ( + command: string, + ): ExternalToolGuardPrepareRequest => ({ + ...request(command), + effectiveCwd: localEffectiveCwd, + }); const guard = createDaemonToolGuard(); await expect( - guard(request('git --git-dir=nested/d/.git branch -D topic')), + guard(localRequest('git --git-dir=nested/d/.git branch -D topic')), ).resolves.toMatchObject({ allowed: false, reason: expect.stringContaining(outsideRepo), @@ -494,20 +641,50 @@ it -C ${outsideRepo} reset --hard`, expect(controlReason).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); }); - it('allows dynamic relocations for read-only subcommands', async () => { + it('denies dynamic relocations even for read-only subcommands', async () => { const guard = createDaemonToolGuard(); + // `status` would run the target repository's core.fsmonitor, so the + // unresolved/dangerous-config check precedes the read-only allowance. await expect( - guard(request('git -C "$OTHER_WORKTREE" status')), - ).resolves.toEqual({ allowed: true }); + guard(request('git -C "$OTHER_WORKTREE" rev-parse')), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('dynamic repository location'), + }); }); - it('does not treat a Git command passed as an argument as executable', async () => { + it.each([ + () => `echo git -C ${outsideRepo} reset --hard`, + () => `nice git -C ${outsideRepo} reset --hard`, + () => `nice -n 5 git -C ${outsideRepo} reset --hard`, + () => `stdbuf -o0 git -C ${outsideRepo} reset --hard`, + () => `setsid git -C ${outsideRepo} reset --hard`, + () => `flock /tmp/daemon-guard-lock git -C ${outsideRepo} reset --hard`, + () => `xargs -I{} git -C ${outsideRepo} reset --hard`, + () => `su -c 'git -C ${outsideRepo} reset --hard'`, + () => `find . -exec git -C ${outsideRepo} reset --hard ;`, + ])( + 'fails closed when an unrecognized program may run a relocated Git command %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('unrecognized program'), + }); + }, + ); + + it('allows commands that mention Git without a relocation marker', async () => { const guard = createDaemonToolGuard(); - await expect( - guard(request(`echo git -C ${outsideRepo} reset --hard`)), - ).resolves.toEqual({ allowed: true }); + await expect(guard(request(`echo 'git status'`))).resolves.toEqual({ + allowed: true, + }); + await expect(guard(request(`grep -rn 'git reset' src`))).resolves.toEqual({ + allowed: true, + }); }); it('short-circuits the external provider after a built-in denial', async () => { @@ -588,16 +765,46 @@ it -C ${outsideRepo} reset --hard`, ); }); - it.each(['workspaceCwd', 'effectiveCwd'])( - 'fails closed without trusted daemon workspace context (%s)', - async (field) => { - const guard = createDaemonToolGuard(); - const call = request('pwd') as unknown as Record; - delete call[field]; + it('fails closed without the trusted effective working directory', 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'); - }, - ); + await expect( + guard(call as unknown as ExternalToolGuardPrepareRequest), + ).rejects.toThrow('trusted workspace context'); + }); + + it('applies the built-in policy to prompt-less shell checks', async () => { + const guard = createDaemonToolGuard(); + + const allowed = request('pwd') as unknown as Record; + delete allowed['promptId']; + await expect( + guard(allowed as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toEqual({ allowed: true }); + + const denied = request( + `git -C ${outsideRepo} reset --hard`, + ) as unknown as Record; + delete denied['promptId']; + await expect( + guard(denied as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('refuses to consult the external provider without a prompt binding', async () => { + const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createDaemonToolGuard(externalGuard); + const call = request('pwd') as unknown as Record; + delete call['promptId']; + + await expect( + guard(call as unknown as ExternalToolGuardPrepareRequest), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('without an active prompt binding'), + }); + expect(externalGuard).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index aa4d2846e8b..0f715400bb3 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { parse } from 'shell-quote'; import { isWithinRoot, - realpathNearestExisting, + realpathNearestExistingAsync, splitCommands, } from '@qwen-code/qwen-code-core'; import { EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS } from '@qwen-code/acp-bridge/externalToolGuard'; @@ -24,21 +24,21 @@ import type { // execute programs configured by the target repository on the managed // (non-tty) output path. `diff`/`log`/`show`/`blame` are excluded: `--output` // writes files and textconv drivers run commands from target-repository -// config. +// config. `grep` takes the same `--textconv` path, and `status` refreshes +// the target index and runs the target repository's core.fsmonitor, so +// neither is read-only here. const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ 'cat-file', 'describe', - 'grep', 'ls-files', 'rev-parse', - 'status', ]); -// Git global options whose next argv entry is consumed as a value (mirrors -// core shell.ts GIT_GLOBAL_FLAGS_TAKES_VALUE). +// Git global options whose next argv entry is consumed as a value. +// `--exec-path` and `--list-cmds` are deliberately absent: real git only +// accepts their `=` form (a bare `--exec-path` prints and exits), so +// modelling them as value-taking would swallow the token that follows them. const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ - '--exec-path', - '--list-cmds', '--namespace', '--super-prefix', ]); @@ -123,12 +123,17 @@ const UNRESOLVED_TARGET_DENIAL_PREFIX = 'Daemon shell guard denied a mutating Git command with an unresolvable repository location: '; const OUTSIDE_TARGET_DENIAL_PREFIX = 'Daemon shell guard denied a mutating Git command outside the session working directory: '; +const UNDECIDABLE_PAYLOAD_DENIAL = + 'Daemon shell guard denied a shell command whose payload could not be resolved before execution.'; +const UNRECOGNIZED_PROGRAM_DENIAL = + 'Daemon shell guard denied a shell command that may run a relocated Git command through an unrecognized program.'; +const PROMPTLESS_PROVIDER_DENIAL = + 'Managed external tool guard cannot consult an external provider without an active prompt binding.'; const MAX_PAYLOAD_RECURSION_DEPTH = 3; interface TrustedDaemonToolGuardRequest extends ExternalToolGuardPrepareRequest { - readonly workspaceCwd: string; readonly effectiveCwd: string; } @@ -307,6 +312,30 @@ function hasGitRelocationMarker(tokens: GuardToken[]): boolean { }); } +// A static token scan cannot prove what an unrecognized program executes. +// When the run still references git and carries a relocation marker — +// possibly inside a quoted payload such as `su -c 'git -C ...'` — fail +// closed instead of letting the program word short-circuit the analysis. +const GIT_WORD_PATTERN = /\bgit\b/; +const TEXT_RELOCATION_MARKER_PATTERN = + /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)=/; + +function runMayConcealRelocatedGit( + run: GuardToken[], + state: PrefixState, + context: GuardEvaluationContext, +): boolean { + if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return false; + return ( + hasGitRelocationMarker(run) || + run.some((token) => TEXT_RELOCATION_MARKER_PATTERN.test(token.text)) || + state.relocations.length > 0 || + state.unresolved || + context.ambientRelocations.length > 0 || + context.ambientUnresolved + ); +} + function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; @@ -477,34 +506,58 @@ function consumeTimeoutWrapper(run: GuardToken[], start: number): number { return index; } -function shellWrapperPayload( - run: GuardToken[], - payloadToken: GuardToken | undefined, -): string | undefined { - if (payloadToken === undefined || payloadToken.dynamic) return undefined; - return payloadToken.text; -} +type ShellWrapperScan = + | { kind: 'none' } + | { kind: 'static'; payload: string } + | { kind: 'dynamic' }; function consumeShellWrapper( run: GuardToken[], start: number, -): string | undefined { +): ShellWrapperScan { let index = start + 1; while (index < run.length) { const token = run[index]!; - if (token.text === '-c' || shellBundleRequestsCommand(token.text)) { - return shellWrapperPayload(run, run[index + 1]); + if (token.text === '-c') { + const payloadToken = run[index + 1]; + if (payloadToken === undefined) { + // `sh -c` with no payload executes nothing. + return { kind: 'static', payload: '' }; + } + if (payloadToken.dynamic) return { kind: 'dynamic' }; + return { kind: 'static', payload: payloadToken.text }; + } + if (token.dynamic) { + // `bash -c$CMD`: the payload is fused into this token and unresolved. + return shellBundleRequestsCommand(token.text) + ? { kind: 'dynamic' } + : { kind: 'none' }; + } + if (shellBundleRequestsCommand(token.text)) { + const fusedPayload = token.text.slice(token.text.indexOf('c') + 1); + if (fusedPayload.length > 0) { + // `bash -c'cmd'`: the payload is fused into the flag token after + // the `c`, not the next argv entry. + return { kind: 'static', payload: fusedPayload }; + } + // `bash -lc 'cmd'`: `c` ends the bundle, so the payload is the next + // argv entry after all. + const payloadToken = run[index + 1]; + if (payloadToken === undefined) { + return { kind: 'static', payload: '' }; + } + if (payloadToken.dynamic) return { kind: 'dynamic' }; + return { kind: 'static', payload: payloadToken.text }; } - if (token.dynamic) return undefined; if (token.text.startsWith('+')) { index++; continue; } - if (!token.text.startsWith('-')) return undefined; - if (token.text === '--') return undefined; + if (!token.text.startsWith('-')) return { kind: 'none' }; + if (token.text === '--') return { kind: 'none' }; index += SHELL_WRAPPER_VALUE_FLAGS.has(token.text) ? 2 : 1; } - return undefined; + return { kind: 'none' }; } type RunAnalysis = @@ -521,15 +574,39 @@ type RunAnalysis = target?: GuardToken; } | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } + | { kind: 'undecidable' } | { kind: 'other'; state: PrefixState }; +// Shell keywords that can lead a split segment without changing what +// executes: `if true; then git ...` arrives as a `then git ...` segment +// because the split happens on `;`/`&&`. Skipping them keeps the real +// program under analysis; bare terminators (`fi`, `done`, ...) leave an +// empty run that classifies as safe. +const LEADING_SHELL_KEYWORDS = new Set([ + '{', + '}', + '!', + 'if', + 'then', + 'else', + 'elif', + 'fi', + 'for', + 'do', + 'done', + 'while', + 'until', + 'in', + 'case', + 'esac', + 'time', + 'coproc', +]); + function analyzeRun(run: GuardToken[]): RunAnalysis { const state: PrefixState = { relocations: [], unresolved: false }; let index = 0; - while ( - index < run.length && - (run[index]!.text === '{' || run[index]!.text === '!') - ) { + while (index < run.length && LEADING_SHELL_KEYWORDS.has(run[index]!.text)) { index++; } while (index < run.length) { @@ -572,7 +649,7 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { if (program === 'eval') { const payloadTokens = run.slice(index + 1); if (payloadTokens.some((payloadToken) => payloadToken.dynamic)) { - return { kind: 'dynamic-program', rest: payloadTokens, state }; + return { kind: 'undecidable' }; } return { kind: 'payload', @@ -584,9 +661,15 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { }; } if (SHELL_WRAPPER_PROGRAMS.has(program)) { - const payload = consumeShellWrapper(run, index); - if (payload === undefined) return { kind: 'other', state }; - return { kind: 'payload', payload, state, propagatesCwd: false }; + const scan = consumeShellWrapper(run, index); + if (scan.kind === 'none') return { kind: 'other', state }; + if (scan.kind === 'dynamic') return { kind: 'undecidable' }; + return { + kind: 'payload', + payload: scan.payload, + state, + propagatesCwd: false, + }; } if (program === 'nohup' || program === 'exec') { index++; @@ -801,12 +884,10 @@ async function evaluateGitInvocation( entryCwd: string | undefined, context: GuardEvaluationContext, ): Promise { - if ( - RELOCATED_READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') && - !invocation.hasOutputFlag - ) { - return undefined; - } + // Command-executing `-c` config and unresolvable relocations are checked + // BEFORE the read-only allowance: `git status` still runs the target + // repository's core.fsmonitor, so a read-only subcommand does not make an + // undecidable invocation safe. if ( invocation.unresolved || invocation.dangerousConfig || @@ -815,6 +896,12 @@ async function evaluateGitInvocation( ) { return denyDynamicRelocation(); } + if ( + RELOCATED_READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') && + !invocation.hasOutputFlag + ) { + return undefined; + } const cwdRelocations: GitEnvRelocation[] = []; const repositoryRelocations: GitEnvRelocation[] = []; @@ -882,7 +969,7 @@ async function evaluateGitInvocation( } for (const { target, kind } of checkedTargets) { - const canonicalTarget = realpathNearestExisting(target); + const canonicalTarget = await realpathNearestExistingAsync(target); let repositoryTarget: string; if (kind === 'git-dir') { try { @@ -902,7 +989,7 @@ async function evaluateGitInvocation( return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalTarget); } } - repositoryTarget = realpathNearestExisting(repositoryTarget); + repositoryTarget = await realpathNearestExistingAsync(repositoryTarget); if (!isWithinRoot(repositoryTarget, context.canonicalEffectiveCwd)) { return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, repositoryTarget); } @@ -917,11 +1004,12 @@ interface CommandEvaluation { async function evaluateCommandWithCwd( command: string, + startCwd: string | undefined, entryCwd: string | undefined, context: GuardEvaluationContext, depth: number, ): Promise { - let trackedCwd = entryCwd; + let trackedCwd = startCwd; for (const segment of splitCommands(command)) { const runs = tokenizeSegment(segment); if (runs === null) { @@ -972,9 +1060,13 @@ async function evaluateCommandWithCwd( ambientUnresolved: context.ambientUnresolved || analysis.state.unresolved, }; + // The payload keeps the outermost run's entry cwd as its + // containment basis: re-basing it to the tracked cwd would let a + // preceding `cd` disappear inside the wrapper. const nested = await evaluateCommandWithCwd( analysis.payload, trackedCwd, + entryCwd, ambient, depth + 1, ); @@ -1010,7 +1102,21 @@ async function evaluateCommandWithCwd( } break; } + case 'undecidable': + return { + denial: { allowed: false, reason: UNDECIDABLE_PAYLOAD_DENIAL }, + cwdAfter: trackedCwd, + }; case 'other': + if (runMayConcealRelocatedGit(run, analysis.state, context)) { + return { + denial: { + allowed: false, + reason: UNRECOGNIZED_PROGRAM_DENIAL, + }, + cwdAfter: trackedCwd, + }; + } break; default: { const exhaustive: never = analysis; @@ -1030,16 +1136,27 @@ async function evaluateBuiltInGuard( const command = request.arguments['command']; if (typeof command !== 'string') return { allowed: true }; + const canonicalEffectiveCwd = await realpathNearestExistingAsync( + request.effectiveCwd, + ); + + // A model-supplied `directory` becomes the containment basis, so it must + // itself stay inside the effective working directory before it is trusted. + let startDirectory = canonicalEffectiveCwd; const startDirectoryValue = request.arguments['directory']; - const startDirectory = - typeof startDirectoryValue === 'string' - ? startDirectoryValue - : request.effectiveCwd; - const canonicalEffectiveCwd = realpathNearestExisting(request.effectiveCwd); + if (typeof startDirectoryValue === 'string') { + startDirectory = await realpathNearestExistingAsync( + path.resolve(request.effectiveCwd, startDirectoryValue), + ); + if (!isWithinRoot(startDirectory, canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, startDirectory); + } + } const { denial } = await evaluateCommandWithCwd( command, startDirectory, + startDirectory, { canonicalEffectiveCwd, ambientRelocations: [], @@ -1055,14 +1172,16 @@ export function createDaemonToolGuard( ): ExternalToolGuardHandler { return async (request) => { const trusted = request as TrustedDaemonToolGuardRequest; - if ( - typeof trusted.workspaceCwd !== 'string' || - typeof trusted.effectiveCwd !== 'string' - ) { + if (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 (trusted.promptId === undefined) { + // Context-less shell checks carry only the built-in policy; the + // external provider is contracted to a live prompt. + return { allowed: false, reason: PROMPTLESS_PROVIDER_DENIAL }; + } if (EXTERNAL_GUARD_UNSUPPORTED_TOOLS.has(request.toolName)) { return { allowed: false, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index db36524c366..c9767f56e86 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3569,7 +3569,6 @@ describe('runQwenServe runtime startup failures', () => { arguments: { command: `git -C ${path.join(os.tmpdir(), 'outside-repo')} reset --hard`, }, - workspaceCwd: tmpDir, effectiveCwd: tmpDir, }), ).resolves.toMatchObject({ allowed: false }); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 055c0689cdb..fa3d6ebfedc 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -135,6 +135,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'; @@ -2799,11 +2800,6 @@ async function runQwenServeImpl( 'qwen serve: required external tool guard handshake succeeded.', ); } - // Dynamic-imported (not at module scope) so the guard's core helper - // imports stay out of the serve fast-path bundle closure. - const { createDaemonToolGuard } = await import( - './daemon-git-worktree-guard.js' - ); const daemonToolGuardHandler = createDaemonToolGuard( externalToolGuardHandler, ); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 7b42b2ac737..7855b8254c7 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -9912,6 +9912,7 @@ describe('CoreToolScheduler Plan shell routing', () => { toolName: ToolNames.SHELL, args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), + sessionId: 'plan-shell-session', }); expect(execute).not.toHaveBeenCalled(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; @@ -9948,6 +9949,7 @@ describe('CoreToolScheduler Plan shell routing', () => { toolName: ToolNames.SHELL, args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), + sessionId: 'plan-shell-session', }); expect(execute).toHaveBeenCalledOnce(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 1d2702cc47e..0a1a8f147aa 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -4479,6 +4479,7 @@ export class CoreToolScheduler { toolName: canonicalName, args: invocation.params as Record, signal, + sessionId: this.config.getSessionId(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/core/src/core/tool-invocation-guard.ts b/packages/core/src/core/tool-invocation-guard.ts index 81edf416be9..98e969b8173 100644 --- a/packages/core/src/core/tool-invocation-guard.ts +++ b/packages/core/src/core/tool-invocation-guard.ts @@ -17,6 +17,13 @@ export interface ToolInvocationGuardContext { * have one; a host that requires it must fail closed when it is absent. */ invocationContext?: Readonly; + /** + * Owning session id from the scheduler's session config. Present even when + * {@link invocationContext} is absent (subagents, cron turns, and resumed + * background agents run without one); a host whose policy only needs + * session scope may fall back to it instead of failing closed. + */ + sessionId?: string; } export type ToolInvocationGuardDecision = diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index 9209ed2a3a3..2763d496734 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -61,6 +61,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -101,6 +102,7 @@ describe('startSpeculation', () => { toolName: 'read_file', args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), + sessionId: 'spec-session', }); expect(execute).not.toHaveBeenCalled(); @@ -125,6 +127,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -165,6 +168,7 @@ describe('startSpeculation', () => { toolName: 'read_file', args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), + sessionId: 'spec-session', }); expect(execute).toHaveBeenCalledOnce(); diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 814ff583cf5..90c6c0fdc17 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -349,6 +349,7 @@ async function runSpeculativeLoop( toolName: canonicalToolName(name), args: invocation.params as Record, signal: state.abortController!.signal, + sessionId: config.getSessionId(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index d403934d3f7..fe08b133765 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -29,6 +29,7 @@ import { expandHomeDir, getProjectHash, realpathNearestExisting, + realpathNearestExistingAsync, _resetValidatePathCacheForTest, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -872,6 +873,46 @@ describe('realpathNearestExisting', () => { ); }); +describe('realpathNearestExistingAsync', () => { + let root: string; + + beforeAll(() => { + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'realpath-nearest-async-')), + ); + fs.mkdirSync(path.join(root, 'real'), { recursive: true }); + fs.writeFileSync(path.join(root, 'real', 'file.txt'), 'x', 'utf8'); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('matches the sync variant across the canonicalization cases', async () => { + const cases = [ + path.join(root, 'real', 'file.txt'), + path.join(root, 'real', 'a', 'b.txt'), + path.resolve(path.sep, 'no', 'such', 'ancestor', 'x'), + ]; + for (const target of cases) { + await expect(realpathNearestExistingAsync(target)).resolves.toBe( + realpathNearestExisting(target), + ); + } + }); + + it.skipIf(process.platform === 'win32')( + 'follows a dangling symlink to its non-existent target', + async () => { + const link = path.join(root, 'dangling-async'); + fs.symlinkSync(path.join(root, 'real', 'absent.txt'), link); + await expect(realpathNearestExistingAsync(link)).resolves.toBe( + path.join(root, 'real', 'absent.txt'), + ); + }, + ); +}); + describe('shortenPath', () => { const sep = path.sep; const sepForRegex = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index a90c9adb9ed..9d3f69aee23 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -486,6 +486,69 @@ export function realpathNearestExisting(inputPath: string): string { } } +async function resolveLeafSymlinkAsync(inputPath: string): Promise { + const maxHops = 40; // POSIX SYMLOOP_MAX + let current = path.resolve(inputPath); + for (let i = 0; i < maxHops; i++) { + let stat: fs.Stats; + try { + stat = await fs.promises.lstat(current); + } catch { + return current; // missing or unreadable — nothing left to follow + } + if (!stat.isSymbolicLink()) { + return current; + } + const target = await fs.promises.readlink(current); + if (path.isAbsolute(target)) { + current = target; + } else { + let parent: string; + try { + parent = await fs.promises.realpath(path.dirname(current)); + } catch { + parent = path.dirname(current); + } + current = path.resolve(parent, target); + } + } + return current; // chain too deep — caller still range-checks the result +} + +/** + * Promise-based {@link realpathNearestExisting} for callers on a shared event + * loop (the daemon guard evaluates shell calls for every workspace/session). + */ +export async function realpathNearestExistingAsync( + inputPath: string, +): Promise { + const resolved = await resolveLeafSymlinkAsync(inputPath); + const missingSegments: string[] = []; + let current = resolved; + + for (;;) { + let exists = true; + try { + await fs.promises.access(current); + } catch { + exists = false; + } + if (exists) break; + const parent = path.dirname(current); + if (parent === current) { + return resolved; + } + missingSegments.unshift(path.basename(current)); + current = parent; + } + + try { + return path.join(await fs.promises.realpath(current), ...missingSegments); + } catch { + return resolved; + } +} + /** * Resolves a path with tilde (~) expansion and relative path resolution. * Handles tilde expansion for home directory and resolves relative paths From 18e997a1da50ef721ebe3f9dfd19bd7d38c6899c Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 8 Aug 2026 12:03:42 +0800 Subject: [PATCH 06/45] fix(serve): restore lazy daemon Git guard import Co-authored-by: Qwen-Coder --- packages/cli/src/serve/run-qwen-serve.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index fa3d6ebfedc..6e2aa920d4c 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -135,7 +135,6 @@ 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'; @@ -2800,6 +2799,10 @@ async function runQwenServeImpl( 'qwen serve: required external tool guard handshake succeeded.', ); } + // Keep the guard's core helper imports out of the serve fast-path bundle. + const { createDaemonToolGuard } = await import( + './daemon-git-worktree-guard.js' + ); const daemonToolGuardHandler = createDaemonToolGuard( externalToolGuardHandler, ); From ab1e3e23842eeffb39643d036801f7ece892f7a5 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 8 Aug 2026 16:52:10 +0800 Subject: [PATCH 07/45] fix(serve): close daemon Git guard shell front-end bypasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six forms below were reproduced against the real guard and confirmed to really escape the boundary with a real shell and git 2.47.3 (the outside worktree was reset, or the textconv marker file was created outside). - `cat-file --textconv`/`--filters` run programs configured by the *target* repository, so the relocated read-only allowance no longer applies when a `--textconv`, `--filters`, or `--output` flag is present, wherever it appears in the invocation. - `export GIT_WORK_TREE= && git reset --hard` hid the relocation in its own segment. `export`/`declare`/`typeset`/`readonly` operands (and plain assignments after `set -a`) are now recorded as exported relocations that apply to every later command in the chain, including wrapper payloads and substitution bodies. - `builtin cd ` masked cwd tracking; `builtin` now takes the same prefix-skipping path as `command`. - `cd -P ` consumed the flag as the directory operand, so containment was evaluated against `/-P` — inside the boundary whenever such a directory exists. `cd`/`pushd` option words are skipped when locating the operand. - `cd && nice git reset --hard` passed because an unrecognized program word was only checked for relocation markers. It is now also denied when the tracked working directory is unknown or already outside the boundary, while the same command inside the boundary stays allowed. - `echo $(git -C reset --hard)` was folded into an opaque token. `$(…)` and backtick bodies are extracted from the raw segment and analysed as nested commands; `$((…))` is stepped over as arithmetic and an unterminated substitution is denied. The guard also covers the `monitor` tool, which spawns its `command` through the same shell with the same `directory` argument and was previously short-circuited to allow by the child before the daemon ever saw it. The shell-executing tool set is shared through acp-bridge so the child and the daemon policy cannot drift apart. --- docs/design/daemon-git-worktree-guard.md | 42 ++- docs/users/qwen-serve.md | 17 +- packages/acp-bridge/src/externalToolGuard.ts | 14 + .../cli/src/acp-integration/acpAgent.test.ts | 35 ++ packages/cli/src/acp-integration/acpAgent.ts | 7 +- .../serve/daemon-git-worktree-guard.test.ts | 222 ++++++++++- .../src/serve/daemon-git-worktree-guard.ts | 345 +++++++++++++++--- 7 files changed, 609 insertions(+), 73 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index dd798b015ac..a1164e17bb6 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -23,7 +23,9 @@ built-in policy allows it. ## Policy -The built-in guard inspects `run_shell_command` calls only. Command splitting +The built-in guard inspects the tools that hand the host a shell command line: +`run_shell_command` and `monitor`, which spawns its `command` through the same +shell and carries the same `directory` argument. Command splitting reuses core `splitCommands`; containment reuses core `realpathNearestExisting` and `isWithinRoot`. It recognizes Git invocations whose repository location is changed by literal forms of: @@ -33,12 +35,16 @@ changed by literal forms of: - `git --git-dir ` and `git --git-dir=` - leading `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, or `GIT_INDEX_FILE` assignments +- the same assignments made through `export`/`declare`/`typeset`/`readonly` + (or plain assignments under `set -a`), which stay in the environment of + every later command in the same chain rather than only their own run - directory-shifting wrapper flags `env -C`/`--chdir` and `sudo -D`/`--chdir` - `cd`, `pushd`, or `popd` builtins earlier in the same command chain, whose targets become the containment basis for later Git invocations in that chain Wrapper prefixes are unwrapped before Git detection: leading env assignments, -`command`, `env` (with its value-taking flags), `sudo` (with its value-taking +`command`, `builtin`, `env` (with its value-taking flags), `sudo` (with its +value-taking flags), `nohup`, `exec`, `timeout `, `sh|bash|dash|zsh|ksh -c` payloads (analyzed recursively, keeping the outermost run's entry cwd as the containment basis so a preceding `cd` cannot disappear inside the wrapper), @@ -47,13 +53,25 @@ containment basis so a preceding `cd` cannot disappear inside the wrapper), and leading shell keywords and reserved words (`{`, `}`, `!`, `if`, `then`, `else`, `elif`, `fi`, `for`, `do`, `done`, `while`, `until`, `in`, `case`, `esac`, `time`, `coproc`), which can lead a split segment without changing -what executes. A segment whose program token cannot be classified fails -closed when the segment still references Git and carries a relocation marker -(token-level or inside a quoted payload), a recorded relocation, or an -unresolved prefix. A `-c` payload that is dynamic (`sh -c "$CMD"`) or fused +what executes. `cd`/`pushd` option words (`-L`, `-P`, `-e`, `-@`, `--`) are +skipped when locating the directory operand, so containment is evaluated +against the directory the shell actually enters. A segment whose program token +cannot be classified fails closed when the segment still references Git and +carries a relocation marker (token-level or inside a quoted payload), a +recorded relocation, an unresolved prefix, or a tracked working directory that +is unknown or already outside the boundary — `cd && nice git reset +--hard` is denied on that last clause. A `-c` payload that is dynamic +(`sh -c "$CMD"`) or fused into the flag token (`bash -c'cmd'`, read from the same token) is analyzed after extraction; an undecidable payload is denied rather than allowed. +Command substitutions (`$(…)` and backticks) execute before the command they +are embedded in, so their bodies are extracted from the raw segment and +analyzed as nested commands against the current tracked directory; their own +`cd` changes stay inside the substitution. `$((…))` is arithmetic and is +stepped over, though a substitution nested inside it is still analyzed. An +unterminated substitution is denied as unparseable. + Relative targets resolve from the command's effective starting directory: `arguments.directory` when present, otherwise the session's current effective working directory. A model-supplied `directory` is itself canonicalized and @@ -80,7 +98,12 @@ Relocated commands whose subcommand is in a small verified read-only set files, and textconv-style drivers execute programs configured by the target repository. `grep` takes the same `--textconv` path, and `status` refreshes the target index and runs the target repository's `core.fsmonitor`, so -neither is read-only here. Any `--output` flag demotes an invocation. Commands with no recognized relocation retain existing behavior. +neither is read-only here. A `--output`, `--textconv`, or `--filters` flag +demotes an invocation wherever it appears: the first writes a file, and the +other two run the target repository's configured drivers even for an +allowlisted subcommand (`git -C cat-file --textconv --path=f HEAD:f` +executes its `diff..textconv` command). Commands with no recognized +relocation retain existing behavior. Dynamic relocation targets (`$` expansions, backticks, leading `~`, globs) and command-executing `-c`/`--config-env` assignments (`alias.*`, `core.editor`, `core.pager`, `credential.helper`, `filter.*`, `difftool.*`, @@ -123,8 +146,9 @@ not a live prompt. Consulting the external provider always requires a prompt binding, so a prompt-less request with a provider attached fails closed. Without a provider the child also resolves every non-shell tool call locally (the built-in policy allows them structurally) instead of paying a -child-daemon-child round trip per call; `run_shell_command` always makes the -round trip. With a provider attached every prompt-bound call still makes it. +child-daemon-child round trip per call; `run_shell_command` and `monitor` +always make the round trip. With a provider attached every prompt-bound call +still makes it. ## Limitations diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 85b7aca5daf..6c9a8918126 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -440,17 +440,22 @@ any capability advertisement. The daemon owns the bound workspace and the session's current effective working directory; both are supplied from trusted session state and never accepted from the ACP child. -The guard inspects `run_shell_command` invocations and denies a mutating Git +The guard inspects the tools that run a shell command line — `run_shell_command` +and `monitor` — and denies a mutating Git command before execution when its repository location resolves outside the session's effective working directory. Relocation is recognized for literal forms of `git -C `, `git --git-dir[=]`, `git --work-tree[=]`, leading -`GIT_DIR`/`GIT_WORK_TREE`/`GIT_COMMON_DIR`/`GIT_INDEX_FILE` assignments, +`GIT_DIR`/`GIT_WORK_TREE`/`GIT_COMMON_DIR`/`GIT_INDEX_FILE` assignments (also +when made through `export`/`declare`/`readonly`, which keep them in the +environment of every later command in the chain), directory-shifting wrapper flags (`env -C`, `sudo -D`), and `cd`, `pushd`, or `popd` builtins earlier in the same command chain. Common wrapper prefixes (`sh -c`, `bash -c`, `eval`, `sudo`, `nohup`, `timeout`, `exec`, `command`, +`builtin`, `env`, path-qualified `git` binaries, and `{ …; }` / `! …` shell syntax) are -unwrapped so the same policy applies to the inner Git invocation. +unwrapped so the same policy applies to the inner Git invocation, and `$(…)` +or backtick substitution bodies are analyzed as commands of their own. Relative targets resolve from the command's effective starting directory (`arguments.directory` when present, otherwise the session's current effective @@ -460,8 +465,10 @@ target that cannot be fully resolved before execution — a dynamic target (`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an unreadable indirection — is denied for mutating or unclassifiable subcommands. Relocated commands whose subcommand is one of a small verified read-only set -(`status`, `rev-parse`, `ls-files`, `grep`, `describe`, `cat-file`) remain -allowed. Commands with no recognized relocation keep their existing behavior. +(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed, unless they +carry a `--output`, `--textconv`, or `--filters` flag: those write a file or +run the target repository's configured drivers. Commands with no recognized +relocation keep their existing behavior. Denials are final and are reported to the model as `Daemon shell guard denied a mutating Git command…`. diff --git a/packages/acp-bridge/src/externalToolGuard.ts b/packages/acp-bridge/src/externalToolGuard.ts index 64a4eb463db..2199e272937 100644 --- a/packages/acp-bridge/src/externalToolGuard.ts +++ b/packages/acp-bridge/src/externalToolGuard.ts @@ -44,6 +44,20 @@ export const EXTERNAL_TOOL_GUARD_REQUIRED_VALUE = 'required-v1'; */ export const EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE = 'attached-v1'; +/** + * Tools whose arguments carry a shell command line the host runs on the + * session's behalf. The daemon's built-in policy inspects exactly these, and + * the ACP child resolves every other tool locally when no external provider + * is attached. Pinned to `ToolNames.SHELL`/`ToolNames.MONITOR` in + * `@qwen-code/qwen-code-core`, which this package deliberately does not + * depend on; `daemon-git-worktree-guard.test.ts` asserts the values still + * match so a rename cannot silently unhook a tool from the guard. + */ +export const SHELL_EXECUTING_TOOL_NAMES: ReadonlySet = new Set([ + 'monitor', + 'run_shell_command', +]); + /** Daemon-local bearer token for the loopback external Tool Guard provider. */ export const EXTERNAL_TOOL_GUARD_TOKEN_ENV = 'QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index e8960ef71ea..04d2dc49677 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -243,6 +243,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ WORKFLOW: 'workflow', CREATE_SUB_SESSION: 'create_sub_session', SEND_MESSAGE: 'send_message', + SHELL: 'run_shell_command', + MONITOR: 'monitor', }, FORK_SUBAGENT_TYPE: 'fork', IMAGE_CAPABILITY: Object.freeze({ @@ -18513,6 +18515,39 @@ describe('createManagedExternalToolGuard', () => { expect(extMethod).toHaveBeenCalledOnce(); }); + // `monitor` spawns its `command` through the same shell, so the built-in + // daemon policy has to see it too. + it('routes monitor commands to the daemon without an external provider', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: ToolNames.MONITOR, + args: { command: 'npm run build' }, + signal: new AbortController().signal, + invocationContext: { + version: 1, + sessionId: 'session-1', + promptId: 'prompt-1', + }, + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-1', + promptId: 'prompt-1', + toolCallId: 'call-1', + toolName: ToolNames.MONITOR, + arguments: { command: 'npm run build' }, + }, + ); + }); + it('stops waiting when the tool invocation is cancelled', async () => { const extMethod = vi.fn( () => new Promise>(() => {}), diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ff20cf24289..cc616e7f5da 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -305,6 +305,7 @@ import { isValidExternalToolGuardDenialReason, PRIVATE_EXTERNAL_TOOL_GUARD_ENV, PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, + SHELL_EXECUTING_TOOL_NAMES, } from '@qwen-code/acp-bridge/externalToolGuard'; import { parseSessionSource, @@ -2805,11 +2806,11 @@ export function createManagedExternalToolGuard( // With only the daemon's built-in policy attached there is no external // provider to consult: every non-shell tool is structurally allowed, // so resolve locally instead of paying a serialized child-daemon-child - // round trip on every tool call. `run_shell_command` still goes to the - // daemon because that is the only tool the built-in policy inspects. + // round trip on every tool call. The shell-executing tools still go to + // the daemon because they are the only ones the built-in policy inspects. if ( !options.externalProviderAttached && - context.toolName !== 'run_shell_command' + !SHELL_EXECUTING_TOOL_NAMES.has(context.toolName) ) { return { allowed: true }; } diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 082c2534c82..c46b5d54d56 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -11,6 +11,7 @@ import path from 'node:path'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { ToolNames } from '@qwen-code/qwen-code-core'; import type { ExternalToolGuardPrepareRequest } from '@qwen-code/acp-bridge/bridgeOptions'; +import { SHELL_EXECUTING_TOOL_NAMES } from '@qwen-code/acp-bridge/externalToolGuard'; import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-')); @@ -257,17 +258,60 @@ describe('createDaemonToolGuard', () => { }, ); + it.each([() => 'bash -c "$CMD"', () => 'sh -c "$CMD" arg'])( + 'fails closed on undecidable shell payloads %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('could not be resolved'), + }); + }, + ); + + // A substitution body runs before the command it is embedded in, so it is + // analysed on its own instead of being folded into an opaque token. it.each([ + () => `echo $(git -C ${outsideRepo} reset --hard)`, + () => `echo "$(cd ${outsideRepo} && git reset --hard)"`, + () => `FOO=$(cd ${outsideRepo} && git reset --hard)`, + () => `echo \`cd ${outsideRepo} && git reset --hard\``, + () => `echo \${x:-$(git -C ${outsideRepo} reset --hard)}`, + () => `echo $(( $(git -C ${outsideRepo} reset --hard) + 1 ))`, () => `sh -c "$(echo git -C ${outsideRepo} reset --hard)"`, - () => 'bash -c "$CMD"', () => `eval "$(echo git -C ${outsideRepo} reset --hard)"`, - ])('fails closed on undecidable shell payloads %#', async (buildCommand) => { + ])( + 'denies a relocated mutation inside a command substitution %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('allows command substitutions that stay inside the boundary', async () => { const guard = createDaemonToolGuard(); - await expect(guard(request(buildCommand()))).resolves.toMatchObject({ - allowed: false, - reason: expect.stringContaining('could not be resolved'), + await expect(guard(request('echo $(date)'))).resolves.toEqual({ + allowed: true, }); + await expect(guard(request('echo $(git rev-parse HEAD)'))).resolves.toEqual( + { allowed: true }, + ); + await expect( + guard(request('echo $(cd nested && git commit -m x)')), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on an unterminated command substitution', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`echo $(git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); }); it.each([ @@ -687,6 +731,174 @@ it -C ${outsideRepo} reset --hard`, }); }); + // An unrecognized program word hides what runs, so a git mention only + // survives while the shell is provably still inside the boundary. + it.each([ + () => `cd ${outsideRepo} && nice git reset --hard`, + () => `cd ${outsideRepo} && ionice -c3 git reset --hard`, + () => `cd ${outsideRepo} && echo x | xargs -I{} git reset --hard`, + () => `cd ${outsideRepo} && find . -maxdepth 0 -exec git reset --hard ;`, + () => `cd ${outsideRepo} && stdbuf -o0 git reset --hard`, + () => 'cd - && nice git reset --hard', + ])( + 'denies an unrecognized program running Git after a cwd shift %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('allows an unrecognized program running Git inside the boundary', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('cd nested && nice git status')), + ).resolves.toEqual({ allowed: true }); + await expect(guard(request('nice git status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // `export`/`declare -x`/`set -a` put a GIT_* relocation in the environment + // of every later command, so it outlives the run that declared it. + it.each([ + () => `export GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `export GIT_WORK_TREE=${outsideRepo} ; git reset --hard`, + () => `export GIT_DIR=${path.join(outsideRepo, '.git')} && git commit -m x`, + () => `declare -x GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `typeset -x GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `readonly GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `set -a && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + () => `set -o allexport; GIT_WORK_TREE=${outsideRepo}; git reset --hard`, + () => `export GIT_WORK_TREE=${outsideRepo} && sh -c 'git reset --hard'`, + () => `export GIT_WORK_TREE=$OTHER && git reset --hard`, + ])( + 'denies a mutation after an exported Git relocation %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves unexported and unrelated assignments alone', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('export FOO=bar && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`export GIT_WORK_TREE=${insideNested} && git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + // Without `export` (or `set -a`) the assignment stays shell-local and + // never reaches the git process. + await expect( + guard(request(`GIT_WORK_TREE=${outsideRepo}; echo done`)), + ).resolves.toEqual({ allowed: true }); + }); + + it.each([ + () => `builtin cd ${outsideRepo} && git reset --hard`, + () => `builtin cd -P ${outsideRepo} && git reset --hard`, + ])('denies a mutation after `builtin cd` %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // `cd -P ` must not resolve containment against `/-P`: that + // basis is inside the boundary whenever such a directory exists. + it.each([ + () => `cd -P ${outsideRepo} && git reset --hard`, + () => `cd -L ${outsideRepo} && git reset --hard`, + () => `cd -eP ${outsideRepo} && git reset --hard`, + () => `cd -- ${outsideRepo} && git reset --hard`, + ])( + 'denies a mutation after an option-carrying cd %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps an option-carrying cd inside the boundary allowed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('cd -P nested && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request('cd -- nested && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + + // The relocated read-only allowance covers subcommands that neither write + // files nor run target-repository programs — flags can revoke both. + it.each([ + () => `git -C ${outsideRepo} cat-file --textconv --path=f.txt HEAD:f.txt`, + () => `git -C ${outsideRepo} cat-file --filters --path=f.txt HEAD:f.txt`, + () => `git -C ${outsideRepo} rev-parse --output=${outsideRepo}/o.txt HEAD`, + () => `git -C ${outsideRepo} ls-files --output ${outsideRepo}/o.txt`, + ])( + 'denies a relocated read-only subcommand carrying a disqualifying flag %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it('still allows the plain relocated read-only subcommands', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + `git -C ${outsideRepo} cat-file -p HEAD:f.txt`, + `git -C ${outsideRepo} describe --tags`, + `git -C ${outsideRepo} ls-files`, + `git -C ${outsideRepo} rev-parse HEAD`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // `monitor` runs its `command` through the same shell as the shell tool. + it('applies the built-in policy to the monitor tool', async () => { + const guard = createDaemonToolGuard(); + const monitorCall = (command: string) => + ({ + ...request(command), + toolName: ToolNames.MONITOR, + }) as ExternalToolGuardPrepareRequest; + + await expect( + guard(monitorCall(`git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + await expect(guard(monitorCall('git status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // The shell-executing set pins ToolNames literals in acp-bridge, which + // cannot import core; a rename must fail here. + it('matches the ToolNames constants for shell-executing tools', () => { + expect(SHELL_EXECUTING_TOOL_NAMES).toEqual( + new Set([ToolNames.SHELL, ToolNames.MONITOR]), + ); + }); + it('short-circuits the external provider after a built-in denial', async () => { const externalGuard = vi.fn().mockResolvedValue({ allowed: true }); const guard = createDaemonToolGuard(externalGuard); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 0f715400bb3..330fdb586b3 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -12,7 +12,10 @@ import { realpathNearestExistingAsync, splitCommands, } from '@qwen-code/qwen-code-core'; -import { EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS } from '@qwen-code/acp-bridge/externalToolGuard'; +import { + EXTERNAL_TOOL_GUARD_MAX_DENIAL_REASON_CHARS, + SHELL_EXECUTING_TOOL_NAMES as SHELL_EXECUTING_TOOLS, +} from '@qwen-code/acp-bridge/externalToolGuard'; import type { ExternalToolGuardHandler, ExternalToolGuardPrepareRequest, @@ -34,6 +37,17 @@ const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ 'rev-parse', ]); +// Flags that break the invariant above wherever they appear: `--output` +// writes a file, and `--textconv`/`--filters` run the *target* repository's +// configured drivers (`git -C cat-file --textconv --path=f HEAD:f` +// executes its `diff..textconv` command). A subcommand from the set +// above carrying one of these is treated as any other relocated command. +const RELOCATED_READ_ONLY_DISQUALIFYING_FLAGS = new Set([ + '--filters', + '--output', + '--textconv', +]); + // Git global options whose next argv entry is consumed as a value. // `--exec-path` and `--list-cmds` are deliberately absent: real git only // accepts their `=` form (a bare `--exec-path` prints and exits), so @@ -320,22 +334,6 @@ const GIT_WORD_PATTERN = /\bgit\b/; const TEXT_RELOCATION_MARKER_PATTERN = /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)=/; -function runMayConcealRelocatedGit( - run: GuardToken[], - state: PrefixState, - context: GuardEvaluationContext, -): boolean { - if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return false; - return ( - hasGitRelocationMarker(run) || - run.some((token) => TEXT_RELOCATION_MARKER_PATTERN.test(token.text)) || - state.relocations.length > 0 || - state.unresolved || - context.ambientRelocations.length > 0 || - context.ambientUnresolved - ); -} - function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; @@ -574,8 +572,10 @@ type RunAnalysis = target?: GuardToken; } | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } + | { kind: 'export'; state: PrefixState } + | { kind: 'all-export' } | { kind: 'undecidable' } - | { kind: 'other'; state: PrefixState }; + | { kind: 'other'; state: PrefixState; assignmentsOnly: boolean }; // Shell keywords that can lead a split segment without changing what // executes: `if true; then git ...` arrives as a `then git ...` segment @@ -603,9 +603,65 @@ const LEADING_SHELL_KEYWORDS = new Set([ 'coproc', ]); +// Builtins that declare variables. `export`/`declare -x`/`typeset -x` put the +// assignment in the environment of every later command in this shell, so a +// GIT_* relocation declared here outlives its own run. `readonly`/`local` are +// treated the same way: over-approximating an assignment as exported can only +// deny, never allow. +const EXPORT_BUILTINS = new Set([ + 'declare', + 'export', + 'local', + 'readonly', + 'typeset', +]); + +// `cd`/`pushd` options that precede the directory operand. Consuming one as +// the target would resolve containment against `/-P` instead of the +// directory the shell actually enters. +const CHDIR_OPTION_PATTERN = /^-[LPe@qs]+$/; + +function findChdirTarget( + run: GuardToken[], + start: number, + variant: 'cd' | 'popd' | 'pushd', +): GuardToken | undefined { + let index = start; + while (index < run.length) { + const token = run[index]!; + if (token.text === '--') return run[index + 1]; + if (variant === 'cd' && CHDIR_OPTION_PATTERN.test(token.text)) { + index++; + continue; + } + // `cd -` (previous directory), `pushd +N`/`-N` (stack rotation) and any + // unrecognized option land somewhere unresolvable: report no target so + // the caller drops the tracked cwd. + if (/^[-+]/.test(token.text)) return undefined; + return token; + } + return undefined; +} + +// `set -a` / `set -o allexport` puts every later assignment in the +// environment, so plain `GIT_DIR=…` runs stop being shell-local. +function requestsAllExport(run: GuardToken[], start: number): boolean { + for (let index = start; index < run.length; index++) { + const text = run[index]!.text; + if (text === '-o' || text === '--') { + if (run[index + 1]?.text === 'allexport') return true; + continue; + } + if (text === 'allexport' || text === '--allexport') return true; + if (/^-[a-zA-Z]*a/.test(text)) return true; + } + return false; +} + function analyzeRun(run: GuardToken[]): RunAnalysis { const state: PrefixState = { relocations: [], unresolved: false }; let index = 0; + let assignments = 0; while (index < run.length && LEADING_SHELL_KEYWORDS.has(run[index]!.text)) { index++; } @@ -613,6 +669,7 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { const token = run[index]!; if (leadingEnvAssignmentKey(token.text) !== null) { recordEnvAssignment(token, state); + assignments++; index++; continue; } @@ -620,11 +677,22 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { return { kind: 'dynamic-program', rest: run.slice(index), state }; } const program = executableBaseName(token); - if (program === 'command') { + // `command git …` and `builtin cd …` run the following word with the + // function/alias lookup suppressed; neither changes what executes. + if (program === 'command' || program === 'builtin') { index++; while (index < run.length && run[index]!.text.startsWith('-')) index++; continue; } + if (EXPORT_BUILTINS.has(program)) { + for (const operand of run.slice(index + 1)) { + recordEnvAssignment(operand, state); + } + return { kind: 'export', state }; + } + if (program === 'set' && requestsAllExport(run, index + 1)) { + return { kind: 'all-export' }; + } if (program === 'env') { const scan = consumeEnvWrapper(run, index, state); if (scan.payload !== undefined) { @@ -679,11 +747,15 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { return { kind: 'git', tokens: run.slice(index), state }; } if (program === 'cd' || program === 'pushd' || program === 'popd') { - return { kind: 'cd', variant: program, target: run[index + 1] }; + return { + kind: 'cd', + variant: program, + target: findChdirTarget(run, index + 1, program), + }; } - return { kind: 'other', state }; + return { kind: 'other', state, assignmentsOnly: false }; } - return { kind: 'other', state }; + return { kind: 'other', state, assignmentsOnly: assignments > 0 }; } interface GitInvocation { @@ -693,7 +765,7 @@ interface GitInvocation { readonly subcommand?: string; readonly unresolved: boolean; readonly dangerousConfig: boolean; - readonly hasOutputFlag: boolean; + readonly hasDisqualifyingFlag: boolean; } function readGitInvocation(tokens: GuardToken[]): GitInvocation { @@ -821,9 +893,11 @@ function readGitInvocation(tokens: GuardToken[]): GitInvocation { break; } - const hasOutputFlag = tokens.some( - (token) => token.text === '--output' || token.text.startsWith('--output='), - ); + const hasDisqualifyingFlag = tokens.some((token) => { + const separator = token.text.indexOf('='); + const flag = separator >= 0 ? token.text.slice(0, separator) : token.text; + return RELOCATED_READ_ONLY_DISQUALIFYING_FLAGS.has(flag); + }); return { cwdTargets, gitDirTargets, @@ -831,7 +905,7 @@ function readGitInvocation(tokens: GuardToken[]): GitInvocation { subcommand, unresolved, dangerousConfig, - hasOutputFlag, + hasDisqualifyingFlag, }; } @@ -898,7 +972,7 @@ async function evaluateGitInvocation( } if ( RELOCATED_READ_ONLY_GIT_SUBCOMMANDS.has(invocation.subcommand ?? '') && - !invocation.hasOutputFlag + !invocation.hasDisqualifyingFlag ) { return undefined; } @@ -997,6 +1071,121 @@ async function evaluateGitInvocation( return undefined; } +/** + * Extract the bodies of `$(…)` and backtick command substitutions from one + * segment. They execute before the command they are embedded in, so a + * relocated mutation hidden inside one (`echo $(git -C reset + * --hard)`) has to be analysed rather than folded into an opaque token. + * Returns null when a substitution is left unterminated. + */ +function extractCommandSubstitutions(segment: string): string[] | null { + const bodies: string[] = []; + let single = false; + let double = false; + let index = 0; + while (index < segment.length) { + const character = segment[index]!; + if (!single && character === '\\' && index + 1 < segment.length) { + index += 2; + continue; + } + if (!single && character === '$' && segment[index + 1] === '(') { + // `$((…))` is arithmetic, not a command. Stepping over the opening + // punctuation keeps any real substitution nested inside it visible. + if (segment[index + 2] === '(') { + index += 3; + continue; + } + const end = findSubstitutionEnd(segment, index + 2); + if (end === -1) return null; + bodies.push(segment.slice(index + 2, end)); + index = end + 1; + continue; + } + if (!single && character === '`') { + let end = index + 1; + while (end < segment.length && segment[end] !== '`') { + if (segment[end] === '\\') end++; + end++; + } + if (end >= segment.length) return null; + bodies.push(segment.slice(index + 1, end)); + index = end + 1; + continue; + } + if (character === "'" && !double) single = !single; + else if (character === '"' && !single) double = !double; + index++; + } + return bodies; +} + +/** Index of the `)` closing a `$(` body opened at `start`, or -1. */ +function findSubstitutionEnd(segment: string, start: number): number { + let single = false; + let double = false; + let depth = 0; + for (let index = start; index < segment.length; index++) { + const character = segment[index]!; + if (!single && character === '\\') { + index++; + continue; + } + if (character === "'" && !double) { + single = !single; + continue; + } + if (character === '"' && !single) { + double = !double; + continue; + } + if (single || double) continue; + if (character === '(') depth++; + else if (character === ')') { + if (depth === 0) return index; + depth--; + } + } + return -1; +} + +/** + * A run whose program word the daemon does not recognize can still run git: + * `nice git reset --hard`, `xargs git …`, `find -exec git …`. The static scan + * cannot prove what it executes, so deny whenever the run mentions git and the + * repository it would act on is not provably the session's own — either + * because a relocation is in play or because the shell has been moved out of + * the boundary by an earlier `cd`. + */ +async function evaluateUnrecognizedRun( + run: GuardToken[], + state: PrefixState, + basisCwd: string | undefined, + entryCwd: string | undefined, + context: GuardEvaluationContext, +): Promise { + if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return undefined; + if ( + hasGitRelocationMarker(run) || + run.some((token) => TEXT_RELOCATION_MARKER_PATTERN.test(token.text)) || + state.relocations.length > 0 || + state.unresolved || + context.ambientRelocations.length > 0 || + context.ambientUnresolved + ) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + if (basisCwd === undefined) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } + if (basisCwd === entryCwd) return undefined; + const canonicalBasis = await realpathNearestExistingAsync(basisCwd); + if (!isWithinRoot(canonicalBasis, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalBasis); + } + return undefined; +} + interface CommandEvaluation { readonly denial?: GuardDenial; readonly cwdAfter: string | undefined; @@ -1010,30 +1199,61 @@ async function evaluateCommandWithCwd( depth: number, ): Promise { let trackedCwd = startCwd; + // Assignments this command exported into the environment of everything that + // runs after them, and whether `set -a` made plain assignments exported. + const exported: PrefixState = { relocations: [], unresolved: false }; + let allExport = false; + // Exported relocations reach every later command, including the ones nested + // inside a wrapper payload or a substitution body. + const activeContext = (): GuardEvaluationContext => + exported.relocations.length > 0 || exported.unresolved + ? { + canonicalEffectiveCwd: context.canonicalEffectiveCwd, + ambientRelocations: [ + ...context.ambientRelocations, + ...exported.relocations, + ], + ambientUnresolved: context.ambientUnresolved || exported.unresolved, + } + : context; for (const segment of splitCommands(command)) { - const runs = tokenizeSegment(segment); + const substitutions = extractCommandSubstitutions(segment); + const runs = substitutions === null ? null : tokenizeSegment(segment); if (runs === null) { return { denial: { allowed: false, reason: UNPARSEABLE_COMMAND_DENIAL }, cwdAfter: trackedCwd, }; } + // A substitution body executes before the command it is embedded in, in a + // subshell of the current directory, so its cwd changes do not escape it. + for (const body of substitutions!) { + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + const nested = await evaluateCommandWithCwd( + body, + trackedCwd, + entryCwd, + activeContext(), + depth + 1, + ); + if (nested.denial) { + return { denial: nested.denial, cwdAfter: trackedCwd }; + } + } for (const run of runs) { const analysis = analyzeRun(run); switch (analysis.kind) { case 'cd': { const target = analysis.target; - if ( - analysis.variant === 'popd' || - target === undefined || - (analysis.variant === 'pushd' && /^[-+]/.test(target.text)) - ) { + if (analysis.variant === 'popd' || target === undefined) { // `popd`, bare `cd` ($HOME), and dir-stack rotations land the // shell somewhere the daemon cannot resolve statically. trackedCwd = undefined; break; } - if (target.text === '-' || isDynamicPathValue(target)) { + if (isDynamicPathValue(target)) { trackedCwd = undefined; break; } @@ -1051,14 +1271,15 @@ async function evaluateCommandWithCwd( if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; } + const inherited = activeContext(); const ambient: GuardEvaluationContext = { - canonicalEffectiveCwd: context.canonicalEffectiveCwd, + canonicalEffectiveCwd: inherited.canonicalEffectiveCwd, ambientRelocations: [ - ...context.ambientRelocations, + ...inherited.ambientRelocations, ...analysis.state.relocations, ], ambientUnresolved: - context.ambientUnresolved || analysis.state.unresolved, + inherited.ambientUnresolved || analysis.state.unresolved, }; // The payload keeps the outermost run's entry cwd as its // containment basis: re-basing it to the tracked cwd would let a @@ -1085,17 +1306,18 @@ async function evaluateCommandWithCwd( analysis.state, trackedCwd, entryCwd, - context, + activeContext(), ); if (denial) return { denial, cwdAfter: trackedCwd }; break; } case 'dynamic-program': { + const inherited = activeContext(); if ( analysis.state.unresolved || analysis.state.relocations.length > 0 || - context.ambientUnresolved || - context.ambientRelocations.length > 0 || + inherited.ambientUnresolved || + inherited.ambientRelocations.length > 0 || hasGitRelocationMarker(analysis.rest) ) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; @@ -1107,17 +1329,38 @@ async function evaluateCommandWithCwd( denial: { allowed: false, reason: UNDECIDABLE_PAYLOAD_DENIAL }, cwdAfter: trackedCwd, }; - case 'other': - if (runMayConcealRelocatedGit(run, analysis.state, context)) { - return { - denial: { - allowed: false, - reason: UNRECOGNIZED_PROGRAM_DENIAL, - }, - cwdAfter: trackedCwd, - }; + case 'export': { + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; + const denial = await evaluateUnrecognizedRun( + run, + analysis.state, + trackedCwd, + entryCwd, + activeContext(), + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + case 'all-export': + allExport = true; + break; + case 'other': { + if (allExport && analysis.assignmentsOnly) { + // `set -a` turned this shell-local assignment into an exported one. + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; } + const denial = await evaluateUnrecognizedRun( + run, + analysis.state, + trackedCwd, + entryCwd, + activeContext(), + ); + if (denial) return { denial, cwdAfter: trackedCwd }; break; + } default: { const exhaustive: never = analysis; void exhaustive; @@ -1132,7 +1375,7 @@ async function evaluateCommandWithCwd( async function evaluateBuiltInGuard( request: TrustedDaemonToolGuardRequest, ): Promise { - if (request.toolName !== 'run_shell_command') return { allowed: true }; + if (!SHELL_EXECUTING_TOOLS.has(request.toolName)) return { allowed: true }; const command = request.arguments['command']; if (typeof command !== 'string') return { allowed: true }; From 45753e045ccb7fdc0bca9bf5b3fdc2bcc1f56c27 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 8 Aug 2026 17:03:16 +0800 Subject: [PATCH 08/45] fix(serve): restore the shell-wrapper analysis return shape The unrecognized-shell-wrapper branch of `analyzeRun` still returned the pre-`assignmentsOnly` `other` shape, so `tsc --build` failed on the union. Every CI job that installs dependencies runs that build, so all three went red on it. --- packages/cli/src/serve/daemon-git-worktree-guard.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 330fdb586b3..e63df75c653 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -730,7 +730,9 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { } if (SHELL_WRAPPER_PROGRAMS.has(program)) { const scan = consumeShellWrapper(run, index); - if (scan.kind === 'none') return { kind: 'other', state }; + if (scan.kind === 'none') { + return { kind: 'other', state, assignmentsOnly: false }; + } if (scan.kind === 'dynamic') return { kind: 'undecidable' }; return { kind: 'payload', From c14ffc4c66c7647fd78a15879d647f82a1d2b8c1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 8 Aug 2026 22:54:47 +0800 Subject: [PATCH 09/45] fix(serve): close the remaining daemon Git guard front-end gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review findings, each reproduced against the real guard before the fix and pinned by a regression test with an in-boundary control. - An unrecognized program word only failed closed on Git-flag markers, so a `cd` inside its quoted payload slipped through (`su -c 'cd && git reset --hard'`, the same through `xargs … sh -c`). `cd`/`pushd` now count as relocation markers in that scan. - The Git word was matched case-sensitively while program classification lowercases, so `nice GIT reset --hard` escaped on a case-insensitive filesystem. - A program word the daemon cannot read at all (`cd && $CMD git reset --hard`, also behind `command`) skipped the containment check that unrecognized program words get. - `export GIT_DIR` with no `=` exported an earlier shell-local assignment invisibly; shell-local GIT_* assignments are now tracked and promoted on a name-only export. `+=` appends and `set -o $OPT` are recorded as unresolved. - `eval` propagated only its cwd, so `eval 'export GIT_WORK_TREE='` lost the export it performed in the current shell. - `--shallow-file` and `--attr-source` were not modelled as value-taking, so their value was read as the subcommand — which ends option parsing and hid every relocation after it. - The command-executing config set missed `core.sshCommand`, `diff..textconv`/`command`, `merge..driver`, `sequence.editor`, `gpg.program`, `pager.*`, `core.askPass` and `uploadpack.packObjectsHook`, and matched case-sensitively although Git config keys are not. - `env -S` accepted a dynamic payload as literal text and ignored the fused `env -S'cmd'` form. Docs: the Non-goals list claimed `CoreToolScheduler` was untouched although this PR adds the scheduler-owned `sessionId` to the guard context; the protocol capability row and the user guide still described the guard as `run_shell_command`-only and mis-stated the denial-message prefixes; and the relocation-never-revoked over-approximation (`unset GIT_DIR` does not clear a recorded relocation) is now stated in Limitations. --- docs/design/daemon-git-worktree-guard.md | 53 ++++++-- docs/developers/qwen-serve-protocol.md | 74 +++++----- docs/users/qwen-serve.md | 13 +- .../serve/daemon-git-worktree-guard.test.ts | 118 ++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 127 +++++++++++++++--- 5 files changed, 313 insertions(+), 72 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index a1164e17bb6..b941d78b128 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -37,7 +37,10 @@ changed by literal forms of: assignments - the same assignments made through `export`/`declare`/`typeset`/`readonly` (or plain assignments under `set -a`), which stay in the environment of - every later command in the same chain rather than only their own run + every later command in the same chain rather than only their own run. A + name-only `export GIT_DIR` exports the value an earlier shell-local + assignment left in that name, and an unresolvable assignment (`+=`, a + dynamic value, `set -o $OPT`) is recorded as an unresolved relocation - directory-shifting wrapper flags `env -C`/`--chdir` and `sudo -D`/`--chdir` - `cd`, `pushd`, or `popd` builtins earlier in the same command chain, whose targets become the containment basis for later Git invocations in that chain @@ -56,14 +59,22 @@ and leading shell keywords and reserved words (`{`, `}`, `!`, `if`, `then`, what executes. `cd`/`pushd` option words (`-L`, `-P`, `-e`, `-@`, `--`) are skipped when locating the directory operand, so containment is evaluated against the directory the shell actually enters. A segment whose program token -cannot be classified fails closed when the segment still references Git and -carries a relocation marker (token-level or inside a quoted payload), a +cannot be classified — including one the daemon cannot read at all (`$CMD`) — +fails closed when the segment still references Git and +carries a relocation marker (token-level or inside a quoted payload, where a +`cd`/`pushd` counts as one because `su -c 'cd && git reset --hard'` +relocates just as effectively as `-C`), a recorded relocation, an unresolved prefix, or a tracked working directory that is unknown or already outside the boundary — `cd && nice git reset ---hard` is denied on that last clause. A `-c` payload that is dynamic +--hard` is denied on that last clause. The Git word is matched +case-insensitively, because the program-word classification lowercases and a +case-insensitive filesystem runs `GIT` and `git` alike. A `-c` payload that is +dynamic (`sh -c "$CMD"`) or fused into the flag token (`bash -c'cmd'`, read from the same token) is analyzed -after extraction; an undecidable payload is denied rather than allowed. +after extraction; `env -S` payloads follow the same rules in both their spaced +and fused (`env -S'cmd'`) forms; an undecidable payload is denied rather than +allowed. Command substitutions (`$(…)` and backticks) execute before the command they are embedded in, so their bodies are extracted from the raw segment and @@ -105,13 +116,22 @@ allowlisted subcommand (`git -C cat-file --textconv --path=f HEAD:f` executes its `diff..textconv` command). Commands with no recognized relocation retain existing behavior. Dynamic relocation targets (`$` expansions, backticks, leading `~`, globs) -and command-executing `-c`/`--config-env` assignments (`alias.*`, -`core.editor`, `core.pager`, `credential.helper`, `filter.*`, `difftool.*`, -`mergetool.*`, `core.fsmonitor`, or values starting with `!`) are denied -regardless of the subcommand — the check runs before the read-only allowance -because even `status` executes a target-repo-configured `core.fsmonitor` — -because the daemon cannot prove that the target remains inside the effective -working directory. +and command-executing `-c`/`--config-env` assignments are denied regardless of +the subcommand — the check runs before the read-only allowance because even +`status` executes a target-repo-configured `core.fsmonitor` — because the +daemon cannot prove that the target remains inside the effective working +directory. The command-executing keys are `alias.*`, `core.askPass`, +`core.editor`, `core.fsmonitor`, `core.pager`, `core.sshCommand`, +`credential.helper`, `diff..command`, `diff..textconv`, +`difftool.*`, `filter.*`, `gpg.program`, `merge..driver`, +`mergetool.*`, `pager.*`, `sequence.editor`, and +`uploadpack.packObjectsHook`, matched case-insensitively because Git config +keys are; any value starting with `!` counts too. + +Git global options that consume the next argv entry (`--namespace`, +`--super-prefix`, `--shallow-file`, `--attr-source`) are modelled as such: +leaving one out would make its value look like the subcommand, ending option +parsing and hiding every relocation after it. `--git-dir` is evaluated by the repository git operates on, with canonicalization before basename handling: a target whose canonical form ends @@ -162,12 +182,19 @@ execution semantics. ## Non-goals - No changes to core `ShellTool`, `ShellToolInvocation`, shell AST parsing, - `PermissionManager`, `evaluatePermissionFlow`, or `CoreToolScheduler`. + `PermissionManager`, or `evaluatePermissionFlow`. `CoreToolScheduler` and + `speculation.ts` gain one additive field — the scheduler-owned `sessionId` + on the guard context — and no behavior change: hosts that ignore it see + exactly the previous flow. - 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: script files run by `bash script.sh` or `source` are not read, and variable values are not tracked across commands. +- No revocation of a recorded relocation: `unset GIT_DIR` and `env -u GIT_DIR` + later in the same chain do not clear an exported GIT\_\* relocation, so such a + chain can be denied even though the real shell would run it inside the + session (a fail-closed false positive, not a bypass). - No heredoc body analysis: Git-shaped text inside a heredoc is scanned as executable lines and can be denied even though the shell never executes it (a fail-closed false positive, not a bypass). diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index a83910b3229..eccedc7c7dd 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -422,43 +422,43 @@ operator diagnostic snapshot documented below. -| Tag | Advertised when … | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | -| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | -| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | -| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to managed `run_shell_command` invocations, so the absence of this tag does not mean no pre-execution denials. | -| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | -| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | -| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | -| `workspace_settings` | the daemon was created with settings persistence available. | -| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | -| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | -| `session_shell_command` | session shell execution is explicitly enabled. | -| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | -| `session_generation` | session generation helpers are available. | -| `workspace_generation` | workspace-scoped generation helpers are available. | -| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | -| `workspace_reload` | workspace reload support is available in the embedded route configuration. | -| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | -| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | -| `channel_control` | daemon-managed channel worker runtime control is wired. | -| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | -| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | -| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | -| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | -| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | -| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | -| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | -| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | -| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | -| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | -| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | -| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | -| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | -| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | -| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | -| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | +| Tag | Advertised when … | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | +| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | +| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to the managed tools that carry a shell command line (`run_shell_command` and `monitor`), so the absence of this tag does not mean no pre-execution denials. | +| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | +| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. | +| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. | +| `workspace_settings` | the daemon was created with settings persistence available. | +| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | +| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | +| `session_shell_command` | session shell execution is explicitly enabled. | +| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | +| `session_generation` | session generation helpers are available. | +| `workspace_generation` | workspace-scoped generation helpers are available. | +| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | +| `workspace_reload` | workspace reload support is available in the embedded route configuration. | +| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. | +| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | +| `channel_control` | daemon-managed channel worker runtime control is wired. | +| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. | +| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | +| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | +| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | +| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. | +| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | +| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | +| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | +| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | +| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | +| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | +| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | +| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | +| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | +| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | +| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 6c9a8918126..f4dbfe144f3 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -465,12 +465,17 @@ target that cannot be fully resolved before execution — a dynamic target (`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an unreadable indirection — is denied for mutating or unclassifiable subcommands. Relocated commands whose subcommand is one of a small verified read-only set -(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed, unless they -carry a `--output`, `--textconv`, or `--filters` flag: those write a file or -run the target repository's configured drivers. Commands with no recognized +(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed, unless the +target is unresolvable, the command carries command-executing `-c` config, or +it carries a `--output`, `--textconv`, or `--filters` flag: those write a file +or run the target repository's configured drivers. Commands with no recognized relocation keep their existing behavior. Denials are final and are reported to the model as -`Daemon shell guard denied a mutating Git command…`. +`Daemon shell guard denied a mutating Git command…` for a resolved, dynamic, +or unresolvable repository location, and as +`Daemon shell guard denied a shell command…` when the command could not be +parsed, its payload could not be resolved, or an unrecognized program may run +a relocated Git command. The guard is a static best-effort policy: it does not interpret script files, track environment variable values across commands, or analyze heredoc bodies diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index c46b5d54d56..9bba947aaac 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -891,6 +891,124 @@ it -C ${outsideRepo} reset --hard`, }); }); + // A quoted payload can relocate through `cd` instead of a Git flag, and an + // unrecognized program word hides which of them runs. + it.each([ + () => `su -c 'cd ${outsideRepo} && git reset --hard'`, + () => `xargs -I{} sh -c 'cd ${outsideRepo} && git reset --hard'`, + // `executableBaseName` lowercases, so an uppercase program word resolves + // to the same binary on a case-insensitive filesystem. + () => `cd ${outsideRepo} && nice GIT reset --hard`, + ])( + 'denies a relocated mutation concealed in an unrecognized program %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // A program word the daemon cannot read is as opaque as an unrecognized one. + it.each([ + () => `cd ${outsideRepo} && $CMD git reset --hard`, + () => `cd ${outsideRepo} && command $CMD git reset --hard`, + ])( + 'denies a dynamic program running Git after a cwd shift %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it.each([ + // `export NAME` with no `=` exports an earlier shell-local assignment. + () => + `GIT_WORK_TREE=${outsideRepo}; export GIT_WORK_TREE; git reset --hard`, + () => + `GIT_DIR=${path.join(outsideRepo, '.git')}\nexport GIT_DIR\ngit commit -m x`, + // `eval` runs in the current shell, so its exports outlive the payload. + () => `eval 'export GIT_WORK_TREE=${outsideRepo}' && git reset --hard`, + () => `eval 'set -a' && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + // `set -o $OPT` can request allexport without naming it. + () => `set -o $OPT && GIT_WORK_TREE=${outsideRepo} && git reset --hard`, + // `+=` appends to an unknown previous value. + () => + `GIT_WORK_TREE+=${outsideRepo} && export GIT_WORK_TREE && git reset --hard`, + ])( + 'denies a mutation after a deferred or unresolvable export %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps shell-local assignments shell-local', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`GIT_WORK_TREE=${outsideRepo}; echo done`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request('FOO=bar; export FOO; git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + + // Config keys are case-insensitive and several beyond the alias set run a + // program of the target repository's choosing. + it.each([ + () => `git -c core.sshCommand='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c CORE.SSHCOMMAND='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c diff.d.textconv='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c merge.d.driver='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c sequence.editor='touch /tmp/x' -C ${outsideRepo} rev-parse`, + ])( + 'denies relocated commands carrying command-executing config %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // An unmodelled value-taking global option makes its value look like the + // subcommand, which ends option parsing and hides the relocation after it. + it.each([ + () => `git --shallow-file /tmp/shallow -C ${outsideRepo} reset --hard`, + () => `git --attr-source HEAD -C ${outsideRepo} reset --hard`, + ])( + 'parses relocations after value-taking global options %#', + async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }, + ); + + it.each([ + () => 'env -S "$CMD"', + () => `env -S'git -C ${outsideRepo} reset --hard'`, + () => `env -iS'git -C ${outsideRepo} reset --hard'`, + ])('handles env -S payload forms %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index e63df75c653..517f2166398 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -52,21 +52,33 @@ const RELOCATED_READ_ONLY_DISQUALIFYING_FLAGS = new Set([ // `--exec-path` and `--list-cmds` are deliberately absent: real git only // accepts their `=` form (a bare `--exec-path` prints and exits), so // modelling them as value-taking would swallow the token that follows them. +// An unmodelled value-taking option is not merely ignored: its value is read +// as the subcommand, which ends option parsing and hides every relocation +// after it (`git --shallow-file

-C reset --hard`). const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ + '--attr-source', '--namespace', + '--shallow-file', '--super-prefix', ]); // `-c`/`--config-env` keys whose values git executes through a shell. A // relocated mutation can be embedded in such a value with no relocation in -// the outer argv, so these mark a mutating invocation unresolved. +// the outer argv, so these mark a mutating invocation unresolved. Git config +// keys are case-insensitive, so these are matched against a lowercased key. const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ /^alias\./, - /^core\.(editor|pager|fsmonitor)$/, + /^core\.(askpass|editor|fsmonitor|pager|sshcommand)$/, /^credential\.helper$/, + /^diff\..+\.(command|textconv)$/, /^difftool\./, /^filter\./, + /^gpg\.program$/, + /^merge\..+\.driver$/, /^mergetool\./, + /^pager\./, + /^sequence\.editor$/, + /^uploadpack\.packobjectshook$/, ]; // Environment assignments that redirect git's repository selection (mirrors @@ -207,11 +219,18 @@ function isDynamicPathValue(token: GuardToken | undefined): boolean { ); } +// `+=` appends to whatever the variable already holds, so the resulting value +// cannot be resolved from this token alone; it is reported like any other +// assignment and `recordEnvAssignment` marks it unresolved. function leadingEnvAssignmentKey(token: string): string | null { - const match = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(token); + const match = /^([A-Za-z_][A-Za-z0-9_]*)\+?=/.exec(token); return match ? match[1]! : null; } +function isAppendAssignment(token: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*\+=/.test(token); +} + function executableBaseName(token: GuardToken): string { const base = token.text.split(/[\\/]/).pop() ?? token.text; return base.toLowerCase().replace(/\.exe$/i, ''); @@ -330,16 +349,24 @@ function hasGitRelocationMarker(tokens: GuardToken[]): boolean { // When the run still references git and carries a relocation marker — // possibly inside a quoted payload such as `su -c 'git -C ...'` — fail // closed instead of letting the program word short-circuit the analysis. -const GIT_WORD_PATTERN = /\bgit\b/; +// Case-insensitive because `executableBaseName` lowercases too, so on a +// case-insensitive filesystem `nice GIT …` runs the same binary. +const GIT_WORD_PATTERN = /\bgit\b/i; +// A `cd`/`pushd` inside such a payload relocates the git that follows it just +// as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). const TEXT_RELOCATION_MARKER_PATTERN = - /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)=/; + /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|\s)(cd|pushd)(\s|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; if (!GIT_DIR_ENV_KEYS.has(key) && !GIT_WORK_TREE_ENV_KEYS.has(key)) return; const value = token.text.slice(token.text.indexOf('=') + 1); - if (token.dynamic || isDynamicPathValue({ text: value, dynamic: false })) { + if ( + token.dynamic || + isAppendAssignment(token.text) || + isDynamicPathValue({ text: value, dynamic: false }) + ) { state.unresolved = true; return; } @@ -385,6 +412,7 @@ function recordChdirValue( interface WrapperScan { next: number; payload?: string; + undecidable?: boolean; } function consumeEnvWrapper( @@ -422,12 +450,27 @@ function consumeEnvWrapper( if (token.text === '-S' || token.text === '--split-string') { const payloadToken = run[index + 1]; if (payloadToken === undefined) return { next: run.length }; + // Mirrors the `-c` payload rule: a payload the daemon cannot read is + // undecidable, not absent. + if (payloadToken.dynamic) return { next: run.length, undecidable: true }; const rest = joinTokenTexts(run.slice(index + 2)); return { next: run.length, payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, }; } + // `env -S'cmd'` / `env -iS'cmd'`: the payload is fused into the flag + // token after the `S`, exactly as `sh -c'cmd'` fuses its own. + if (/^-[A-Za-z]*S/.test(token.text) && !token.text.startsWith('--')) { + const fused = token.text.slice(token.text.indexOf('S') + 1); + if (fused.length > 0) { + const rest = joinTokenTexts(run.slice(index + 1)); + return { + next: run.length, + payload: rest ? `${fused} ${rest}` : fused, + }; + } + } if (ENV_VALUE_FLAGS.has(token.text)) { index += 2; continue; @@ -572,7 +615,7 @@ type RunAnalysis = target?: GuardToken; } | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } - | { kind: 'export'; state: PrefixState } + | { kind: 'export'; state: PrefixState; operands: GuardToken[] } | { kind: 'all-export' } | { kind: 'undecidable' } | { kind: 'other'; state: PrefixState; assignmentsOnly: boolean }; @@ -647,7 +690,10 @@ function findChdirTarget( // environment, so plain `GIT_DIR=…` runs stop being shell-local. function requestsAllExport(run: GuardToken[], start: number): boolean { for (let index = start; index < run.length; index++) { - const text = run[index]!.text; + const token = run[index]!; + const text = token.text; + // `set -o $OPT` can request allexport without naming it. + if (token.dynamic) return true; if (text === '-o' || text === '--') { if (run[index + 1]?.text === 'allexport') return true; continue; @@ -685,16 +731,16 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { continue; } if (EXPORT_BUILTINS.has(program)) { - for (const operand of run.slice(index + 1)) { - recordEnvAssignment(operand, state); - } - return { kind: 'export', state }; + const operands = run.slice(index + 1); + for (const operand of operands) recordEnvAssignment(operand, state); + return { kind: 'export', state, operands }; } if (program === 'set' && requestsAllExport(run, index + 1)) { return { kind: 'all-export' }; } if (program === 'env') { const scan = consumeEnvWrapper(run, index, state); + if (scan.undecidable) return { kind: 'undecidable' }; if (scan.payload !== undefined) { return { kind: 'payload', @@ -780,7 +826,9 @@ function readGitInvocation(tokens: GuardToken[]): GitInvocation { const recordConfigAssignment = (value: string): void => { const separator = value.indexOf('='); - const key = separator >= 0 ? value.slice(0, separator) : value; + const key = ( + separator >= 0 ? value.slice(0, separator) : value + ).toLowerCase(); const assignment = separator >= 0 ? value.slice(separator + 1) : ''; if ( GIT_COMMAND_CONFIG_KEY_PATTERNS.some((pattern) => pattern.test(key)) || @@ -1191,6 +1239,10 @@ async function evaluateUnrecognizedRun( interface CommandEvaluation { readonly denial?: GuardDenial; readonly cwdAfter: string | undefined; + // Environment state the payload leaves behind. Only a construct that runs + // in the current shell (`eval`) propagates it back to the caller. + readonly exportedAfter?: PrefixState; + readonly allExportAfter?: boolean; } async function evaluateCommandWithCwd( @@ -1205,6 +1257,9 @@ async function evaluateCommandWithCwd( // runs after them, and whether `set -a` made plain assignments exported. const exported: PrefixState = { relocations: [], unresolved: false }; let allExport = false; + // GIT_* assignments made without `export`. They stay shell-local until a + // name-only `export GIT_DIR` promotes them into the environment. + const shellLocals = new Map(); // Exported relocations reach every later command, including the ones nested // inside a wrapper payload or a substitution body. const activeContext = (): GuardEvaluationContext => @@ -1297,7 +1352,14 @@ async function evaluateCommandWithCwd( return { denial: nested.denial, cwdAfter: trackedCwd }; } if (analysis.propagatesCwd) { + // `eval` runs in the current shell, so everything it changed — + // the cwd, exported relocations and `set -a` — outlives it. trackedCwd = nested.cwdAfter; + if (nested.exportedAfter) { + exported.relocations.push(...nested.exportedAfter.relocations); + if (nested.exportedAfter.unresolved) exported.unresolved = true; + } + if (nested.allExportAfter) allExport = true; } break; } @@ -1324,6 +1386,16 @@ async function evaluateCommandWithCwd( ) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; } + // A program word the daemon cannot read is at least as opaque as an + // unrecognized one, so it answers to the same containment rule. + const denial = await evaluateUnrecognizedRun( + analysis.rest, + analysis.state, + trackedCwd, + entryCwd, + inherited, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; break; } case 'undecidable': @@ -1334,6 +1406,13 @@ async function evaluateCommandWithCwd( case 'export': { exported.relocations.push(...analysis.state.relocations); if (analysis.state.unresolved) exported.unresolved = true; + // `export GIT_DIR` with no `=` exports whatever an earlier + // shell-local assignment left in that name. + for (const operand of analysis.operands) { + if (leadingEnvAssignmentKey(operand.text) !== null) continue; + const pending = shellLocals.get(operand.text); + if (pending) recordEnvAssignment(pending, exported); + } const denial = await evaluateUnrecognizedRun( run, analysis.state, @@ -1348,10 +1427,18 @@ async function evaluateCommandWithCwd( allExport = true; break; case 'other': { - if (allExport && analysis.assignmentsOnly) { - // `set -a` turned this shell-local assignment into an exported one. - exported.relocations.push(...analysis.state.relocations); - if (analysis.state.unresolved) exported.unresolved = true; + if (analysis.assignmentsOnly) { + if (allExport) { + // `set -a` turned this shell-local assignment into an exported + // one straight away. + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; + } else { + for (const token of run) { + const key = leadingEnvAssignmentKey(token.text); + if (key !== null) shellLocals.set(key, token); + } + } } const denial = await evaluateUnrecognizedRun( run, @@ -1371,7 +1458,11 @@ async function evaluateCommandWithCwd( } } } - return { cwdAfter: trackedCwd }; + return { + cwdAfter: trackedCwd, + exportedAfter: exported, + allExportAfter: allExport, + }; } async function evaluateBuiltInGuard( From 7f66be62bc5b919366d192a7c57d116ae054833e Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 8 Aug 2026 23:01:15 +0800 Subject: [PATCH 10/45] fix(serve): resolve the repository Git discovers, not just the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more escapes from the round-4 review, both reproduced with a real shell and git 2.47.3 before the fix. - A `.git` gitfile inside the boundary redirects Git to an outside repository: `git -C /decoy commit` moved the outside repo's HEAD while the directory itself passed containment. A `cwd` target now resolves the first `.git` between it and the boundary through the same `resolveGitDirRepository` path `--git-dir` targets use, which keeps a linked-worktree session working because its own gitfile resolves back to that worktree's checkout — pinned by a test that runs a session whose `.git` points at an outside admin directory. - `cd -P /..` lands the shell in the parent of the symlink's real target, which a lexical resolve places back inside the boundary. A `-P` cd whose target contains `..` now drops the tracked directory. The default logical form is unchanged and still allowed, because bash resolves it against the logical path and really does stay inside. --- .../serve/daemon-git-worktree-guard.test.ts | 56 +++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 69 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 9bba947aaac..3c95f25d7a6 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1009,6 +1009,62 @@ it -C ${outsideRepo} reset --hard`, }); }); + // Git discovers its repository by walking up from the working directory, so + // an in-boundary directory can still hand it an outside repository. + it('denies a relocation into a directory whose .git redirects outside', async () => { + const decoy = path.join(effectiveCwd, 'gitfile-decoy'); + await mkdir(decoy, { recursive: true }); + await writeFile( + path.join(decoy, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git -C gitfile-decoy reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + await expect( + guard(request('cd gitfile-decoy && git commit -m x')), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('keeps a linked-worktree session working when its own .git points outside', async () => { + const linkedRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-wt-')); + const session = path.join(linkedRoot, 'checkout'); + const adminDir = path.join(linkedRoot, 'main', '.git', 'worktrees', 'live'); + await Promise.all([ + mkdir(path.join(session, 'nested'), { recursive: true }), + mkdir(adminDir, { recursive: true }), + ]); + await writeFile(path.join(session, '.git'), `gitdir: ${adminDir}\n`); + await writeFile( + path.join(adminDir, 'gitdir'), + `${path.join(session, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + const call = { + ...request('cd nested && git commit -m x'), + effectiveCwd: session, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toEqual({ allowed: true }); + await rm(linkedRoot, { recursive: true, force: true }); + }); + + it('resolves cd -P through symlinks before applying ..', async () => { + await symlink(outsideRepo, path.join(effectiveCwd, 'outward-link'), 'dir'); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('cd -P outward-link/.. && git reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + // The default (logical) form really does stay inside: bash resolves + // `link/..` against the logical path, so allowing it matches the shell. + await expect( + guard(request('cd outward-link/.. && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 517f2166398..e10fd35fe72 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -613,6 +613,7 @@ type RunAnalysis = kind: 'cd'; variant: 'cd' | 'popd' | 'pushd'; target?: GuardToken; + physical?: boolean; } | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } | { kind: 'export'; state: PrefixState; operands: GuardToken[] } @@ -799,6 +800,11 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { kind: 'cd', variant: program, target: findChdirTarget(run, index + 1, program), + // `cd -P` resolves each component through its symlinks before + // applying `..`, which a lexical resolve cannot reproduce. + physical: run + .slice(index + 1) + .some((token) => /^-[A-Za-z]*P/.test(token.text)), }; } return { kind: 'other', state, assignmentsOnly: false }; @@ -995,6 +1001,42 @@ async function resolveGitDirRepository( throw new Error('gitdir indirection too deep'); } +/** + * Git discovers its repository by walking up from the working directory, so a + * directory that is itself inside the boundary can still hand git an outside + * repository through a `.git` gitfile (`gitdir: /.git`). Resolve the + * first `.git` between the target and the boundary the same way `--git-dir` + * targets are resolved — which keeps a linked worktree working, because its + * own gitfile resolves back to that worktree's checkout. Returns undefined + * when nothing is discovered inside the boundary; throws when an indirection + * cannot be read. + */ +async function resolveDiscoveredRepository( + startDirectory: string, + boundary: string, +): Promise { + let current = startDirectory; + for (let depth = 0; depth < 64; depth++) { + const candidate = path.join(current, '.git'); + let exists = true; + try { + await stat(candidate); + } catch { + exists = false; + } + if (exists) { + return resolveGitDirRepository( + await realpathNearestExistingAsync(candidate), + ); + } + if (current === boundary) return undefined; + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } + return undefined; +} + interface GuardEvaluationContext { readonly canonicalEffectiveCwd: string; readonly ambientRelocations: readonly GitEnvRelocation[]; @@ -1117,6 +1159,26 @@ async function evaluateGitInvocation( if (!isWithinRoot(repositoryTarget, context.canonicalEffectiveCwd)) { return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, repositoryTarget); } + // The directory git runs in is inside the boundary, but the repository it + // discovers from there may not be. + if (kind === 'cwd') { + let discovered: string | undefined; + try { + discovered = await resolveDiscoveredRepository( + repositoryTarget, + context.canonicalEffectiveCwd, + ); + } catch { + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, repositoryTarget); + } + if (discovered !== undefined) { + const canonicalDiscovered = + await realpathNearestExistingAsync(discovered); + if (!isWithinRoot(canonicalDiscovered, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalDiscovered); + } + } + } } return undefined; } @@ -1314,6 +1376,13 @@ async function evaluateCommandWithCwd( trackedCwd = undefined; break; } + if (analysis.physical && target.text.split(/[\\/]/).includes('..')) { + // Under `-P` the shell resolves `link/..` to the parent of the + // symlink's real target, which a lexical resolve would place back + // inside the boundary. + trackedCwd = undefined; + break; + } if (path.isAbsolute(target.text)) { trackedCwd = target.text; break; From b11585bc62ca01ed22309f30cef3b3d26394a414 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 8 Aug 2026 23:33:01 +0800 Subject: [PATCH 11/45] fix(serve): match chdir, brace-expansion and here-string semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more escapes from the round-4 review, each reproduced against the real guard and then confirmed with a real shell and git 2.47.3. - `git -C /.. reset --hard` escaped: git's `-C` reaches the kernel as a chdir, which resolves each component's symlinks, while the guard collapsed `..` lexically and landed back inside the boundary. `-C`, `env -C`, `sudo -D` and `cd -P` now resolve physically, component by component; bash's default `cd` stays lexical because that is what the shell itself does. - `git {-C,} reset --hard` escaped: brace expansion happens after this parse, so the tokens git receives were never the tokens the guard saw. A brace-expansion token now marks the invocation unresolved. - `sh <<< 'git -C reset --hard'` escaped: the tokenizer dropped redirect operands, and a here-string carries its whole payload in the command line. Redirect operands stay in the run, so the here-string is scanned like any other token; ordinary `>`/`2>` targets are inert text and a regression test keeps them allowed. Checked and not reproduced, so left alone: `describe --dirty` did not rewrite the target index, `GIT_OBJECT_DIRECTORY=` did not write objects there, and `bash -o allexport -c '…'` cannot export into the parent shell because the payload runs in a subprocess. --- .../serve/daemon-git-worktree-guard.test.ts | 49 ++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 66 +++++++++++++------ 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 3c95f25d7a6..c94ba810dd8 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1065,6 +1065,55 @@ it -C ${outsideRepo} reset --hard`, ).resolves.toEqual({ allowed: true }); }); + // `git -C` reaches the kernel as a chdir, which resolves each component's + // symlinks — unlike bash's default (logical) `cd`. + it('resolves git -C physically through symlinks', async () => { + const outward = path.join(effectiveCwd, 'physical-link'); + await symlink(path.join(outsideRepo, 'sub'), outward, 'dir'); + await mkdir(path.join(outsideRepo, 'sub'), { recursive: true }); + + const guard = createDaemonToolGuard(); + await expect( + guard(request('git -C physical-link/.. reset --hard')), + ).resolves.toMatchObject({ allowed: false }); + await expect(guard(request('git -C nested/.. status'))).resolves.toEqual({ + allowed: true, + }); + }); + + // A here-string carries its payload in the command line itself. + it.each([ + () => `sh <<< 'git -C ${outsideRepo} reset --hard'`, + () => `bash -s <<< 'cd ${outsideRepo} && git reset --hard'`, + ])('denies a payload delivered by here-string %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps ordinary redirects allowed', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request('git -C nested status > out.txt 2> err.txt')), + ).resolves.toEqual({ allowed: true }); + }); + + // Brace expansion happens after this parse, so the tokens git receives are + // not the tokens the guard saw. + it.each([ + () => `git {-C,${outsideRepo}} reset --hard`, + () => `git -C{,${outsideRepo}} reset --hard`, + ])('denies a relocation hidden in a brace expansion %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index e10fd35fe72..26851f8a2a1 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -210,12 +210,17 @@ function denyTarget(prefix: string, target: string): GuardDenial { }; } +// `{a,b}` is expanded by the shell after this parse, so `git {-C,} +// reset --hard` reaches git as a relocation the token scan never saw. +const BRACE_EXPANSION_PATTERN = /\{[^{}]*,[^{}]*\}/; + function isDynamicPathValue(token: GuardToken | undefined): boolean { return ( token === undefined || token.dynamic || token.text.includes('`') || - token.text.startsWith('~') + token.text.startsWith('~') || + BRACE_EXPANSION_PATTERN.test(token.text) ); } @@ -258,14 +263,9 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { return null; } const runs: GuardToken[][] = [[]]; - let skipRedirectOperand = false; for (let index = 0; index < parsed.length; index++) { const token = parsed[index]; if (typeof token === 'string') { - if (skipRedirectOperand) { - skipRedirectOperand = false; - continue; - } // A `$(...)` substitution arrives as a string ending in `$` followed // by an `(` operator. Consume the whole body as one opaque dynamic // token so the assignment/flag it belongs to keeps its place instead @@ -291,7 +291,6 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { } } runs.at(-1)!.push({ text: token, dynamic: true }); - skipRedirectOperand = false; continue; } } @@ -305,7 +304,6 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { if ('comment' in token) break; if (!('op' in token)) return null; const op = token.op; - skipRedirectOperand = false; if (op === 'glob') { // Glob expansion is resolved by the shell at runtime; the daemon // cannot evaluate it statically. @@ -321,7 +319,9 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { continue; } if (REDIRECT_OPERATORS.has(op)) { - skipRedirectOperand = true; + // The operand stays in the run: a here-string (`sh <<< 'git -C … reset + // --hard'`) carries an executable payload, and an ordinary redirect + // target is inert text that no analysis step acts on. continue; } runs.push([]); @@ -870,7 +870,7 @@ function readGitInvocation(tokens: GuardToken[]): GitInvocation { let index = 1; while (index < tokens.length) { const token = tokens[index]!; - if (token.dynamic) { + if (token.dynamic || BRACE_EXPANSION_PATTERN.test(token.text)) { unresolved = true; index++; continue; @@ -1001,6 +1001,29 @@ async function resolveGitDirRepository( throw new Error('gitdir indirection too deep'); } +/** + * Resolve a directory change the way `chdir(2)` does — following each + * component's symlinks before applying the next one. `git -C` and `cd -P` use + * it, so `-C /..` lands in the parent of the symlink's real target, + * while a lexical `path.resolve` would collapse it back to the starting + * directory. Bash's default `cd` is logical and keeps the lexical behavior. + */ +async function resolvePhysicalPath( + base: string, + target: string, +): Promise { + let current = path.isAbsolute(target) ? path.parse(target).root : base; + for (const segment of target.split(/[\\/]+/)) { + if (segment === '' || segment === '.') continue; + if (segment === '..') { + current = path.dirname(await realpathNearestExistingAsync(current)); + continue; + } + current = await realpathNearestExistingAsync(path.join(current, segment)); + } + return current; +} + /** * Git discovers its repository by walking up from the working directory, so a * directory that is itself inside the boundary can still hand git an outside @@ -1098,14 +1121,12 @@ async function evaluateGitInvocation( repositoryRelocations.length > 0; if (!relocated) return undefined; + // `-C`, `env -C` and `sudo -D` all reach the kernel as a chdir, so each + // component resolves through its symlinks before the next one applies. let gitCwd = basisCwd; for (const relocation of cwdRelocations) { - if (path.isAbsolute(relocation.target)) { - gitCwd = relocation.target; - continue; - } - if (gitCwd === undefined) break; - gitCwd = path.resolve(gitCwd, relocation.target); + if (gitCwd === undefined && !path.isAbsolute(relocation.target)) break; + gitCwd = await resolvePhysicalPath(gitCwd ?? '', relocation.target); } if (gitCwd === undefined) { return denyDynamicRelocation(); @@ -1376,11 +1397,14 @@ async function evaluateCommandWithCwd( trackedCwd = undefined; break; } - if (analysis.physical && target.text.split(/[\\/]/).includes('..')) { - // Under `-P` the shell resolves `link/..` to the parent of the - // symlink's real target, which a lexical resolve would place back - // inside the boundary. - trackedCwd = undefined; + if (analysis.physical) { + // `cd -P` resolves each component through its symlinks, so + // `link/..` is the parent of the symlink's real target rather + // than the directory the link sits in. + trackedCwd = + trackedCwd === undefined && !path.isAbsolute(target.text) + ? undefined + : await resolvePhysicalPath(trackedCwd ?? '', target.text); break; } if (path.isAbsolute(target.text)) { From dfeb461d0d6305735f45b2566c35ec1598fc7f4c Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 9 Aug 2026 00:00:31 +0800 Subject: [PATCH 12/45] fix(serve): treat relocated git describe as a target-repo write Co-authored-by: Qwen-Coder --- docs/design/daemon-git-worktree-guard.md | 9 +++++---- docs/users/qwen-serve.md | 2 +- .../serve/daemon-git-worktree-guard.test.ts | 19 ++++++++++++++++++- .../src/serve/daemon-git-worktree-guard.ts | 8 ++++---- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index b941d78b128..79ff4018b42 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -104,12 +104,13 @@ hold: 2. its Git subcommand is mutating or cannot be classified as read-only. Relocated commands whose subcommand is in a small verified read-only set -(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed. `diff`, +(`rev-parse`, `ls-files`, `cat-file`) remain allowed. `diff`, `log`, `show`, and `blame` are excluded from that set: `--output` writes files, and textconv-style drivers execute programs configured by the target -repository. `grep` takes the same `--textconv` path, and `status` refreshes -the target index and runs the target repository's `core.fsmonitor`, so -neither is read-only here. A `--output`, `--textconv`, or `--filters` flag +repository. `grep` takes the same `--textconv` path, `status` refreshes +the target index and runs the target repository's `core.fsmonitor`, and +`describe` refreshes the target index even without `--dirty`, so none of +them is read-only here. A `--output`, `--textconv`, or `--filters` flag demotes an invocation wherever it appears: the first writes a file, and the other two run the target repository's configured drivers even for an allowlisted subcommand (`git -C cat-file --textconv --path=f HEAD:f` diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index f4dbfe144f3..e6553ef12de 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -465,7 +465,7 @@ target that cannot be fully resolved before execution — a dynamic target (`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an unreadable indirection — is denied for mutating or unclassifiable subcommands. Relocated commands whose subcommand is one of a small verified read-only set -(`rev-parse`, `ls-files`, `describe`, `cat-file`) remain allowed, unless the +(`rev-parse`, `ls-files`, `cat-file`) remain allowed, unless the target is unresolvable, the command carries command-executing `-c` config, or it carries a `--output`, `--textconv`, or `--filters` flag: those write a file or run the target repository's configured drivers. Commands with no recognized diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index c94ba810dd8..918532ede1e 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -866,7 +866,6 @@ it -C ${outsideRepo} reset --hard`, for (const command of [ `git -C ${outsideRepo} cat-file -p HEAD:f.txt`, - `git -C ${outsideRepo} describe --tags`, `git -C ${outsideRepo} ls-files`, `git -C ${outsideRepo} rev-parse HEAD`, ]) { @@ -874,6 +873,24 @@ it -C ${outsideRepo} reset --hard`, } }); + // `describe` refreshes the target repository's index even without + // `--dirty`/`--broken`, so it does not qualify as read-only (verified with + // real git: the outside repo's .git/index is rewritten). + it.each([ + () => `git -C ${outsideRepo} describe`, + () => `git -C ${outsideRepo} describe --tags`, + () => `git -C ${outsideRepo} describe --dirty`, + () => `git -C ${outsideRepo} describe --always --dirty`, + () => `git -C ${outsideRepo} describe --broken`, + ])('denies a relocated describe %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + // `monitor` runs its `command` through the same shell as the shell tool. it('applies the built-in policy to the monitor tool', async () => { const guard = createDaemonToolGuard(); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 26851f8a2a1..bf1ba3b4ef3 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -27,12 +27,12 @@ import type { // execute programs configured by the target repository on the managed // (non-tty) output path. `diff`/`log`/`show`/`blame` are excluded: `--output` // writes files and textconv drivers run commands from target-repository -// config. `grep` takes the same `--textconv` path, and `status` refreshes -// the target index and runs the target repository's core.fsmonitor, so -// neither is read-only here. +// config. `grep` takes the same `--textconv` path, `status` refreshes the +// target index and runs the target repository's core.fsmonitor, and +// `describe` refreshes the target index even without `--dirty`, so none of +// them is read-only here. const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ 'cat-file', - 'describe', 'ls-files', 'rev-parse', ]); From af36ecafb5fa832f32e09abdec6fcf62e1d93432 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 00:12:09 +0800 Subject: [PATCH 13/45] docs(serve): state what git describe actually rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping `describe` out of the relocated read-only set is right, but the reason as written does not match git 2.47.3. Measured against a real repository with a stale stat cache (mtime-only touch), `describe --dirty`, `--broken` and `--always --dirty` rewrite the target repository's `.git/index`, while a plain `describe`, `--tags` and `--always` leave it untouched. The subcommand still belongs outside the set — the flag is one token away from any describe a model writes — so only the comments and the design doc change. --- docs/design/daemon-git-worktree-guard.md | 5 +++-- packages/cli/src/serve/daemon-git-worktree-guard.test.ts | 8 +++++--- packages/cli/src/serve/daemon-git-worktree-guard.ts | 5 +++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index 79ff4018b42..618e376f30f 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -109,8 +109,9 @@ Relocated commands whose subcommand is in a small verified read-only set files, and textconv-style drivers execute programs configured by the target repository. `grep` takes the same `--textconv` path, `status` refreshes the target index and runs the target repository's `core.fsmonitor`, and -`describe` refreshes the target index even without `--dirty`, so none of -them is read-only here. A `--output`, `--textconv`, or `--filters` flag +`describe --dirty`/`--broken` rewrite the target index whenever its stat +cache is stale — a plain `describe` does not, but the flag is one token +away — so none of them is read-only here. A `--output`, `--textconv`, or `--filters` flag demotes an invocation wherever it appears: the first writes a file, and the other two run the target repository's configured drivers even for an allowlisted subcommand (`git -C cat-file --textconv --path=f HEAD:f` diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 918532ede1e..5651e2191e2 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -873,9 +873,11 @@ it -C ${outsideRepo} reset --hard`, } }); - // `describe` refreshes the target repository's index even without - // `--dirty`/`--broken`, so it does not qualify as read-only (verified with - // real git: the outside repo's .git/index is rewritten). + // `describe --dirty`/`--broken` rewrite the target repository's index + // whenever its stat cache is stale (measured on git 2.47.3: a plain + // `describe`/`--tags`/`--always` leaves .git/index untouched). The whole + // subcommand stays out of the read-only set because the flag is one token + // away from any describe a model writes. it.each([ () => `git -C ${outsideRepo} describe`, () => `git -C ${outsideRepo} describe --tags`, diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index bf1ba3b4ef3..8e0fa855800 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -29,8 +29,9 @@ import type { // writes files and textconv drivers run commands from target-repository // config. `grep` takes the same `--textconv` path, `status` refreshes the // target index and runs the target repository's core.fsmonitor, and -// `describe` refreshes the target index even without `--dirty`, so none of -// them is read-only here. +// `describe --dirty`/`--broken` rewrite the target index whenever its stat +// cache is stale (measured on git 2.47.3; a plain `describe` does not, but +// the flag is one token away), so none of them is read-only here. const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ 'cat-file', 'ls-files', From 8112fae46a5e2ff4bfcc79111e6e00d97af2e782 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 00:15:56 +0800 Subject: [PATCH 14/45] test(serve): cover the provider-attached marker with a real handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only assertion on the provider marker was the negative case, so a break in the attached path — the marker comparison or the conditional child-env spread — would have gone unnoticed. This drives a loopback provider through the real `/v1/handshake` and asserts the child env carries the attached marker alongside the plumbing one. Verified load-bearing: forcing the marker to `undefined` fails it. --- packages/cli/src/serve/run-qwen-serve.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index c9767f56e86..7c901772b0f 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3587,6 +3587,79 @@ describe('runQwenServe runtime startup failures', () => { } }); + // The negative side of the provider marker is asserted above. This is the + // attached side, driven by a real handshake against a loopback provider so + // the marker, the composed guard and the child env are all exercised. + it('forwards the provider-attached marker when a real provider handshakes', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-guard-provider-')), + ); + const provider = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + protocolVersion: number; + nonce?: string; + }; + response.statusCode = 200; + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + protocolVersion: body.protocolVersion, + nonce: body.nonce, + capabilities: { prepare: true }, + }), + ); + }); + }); + await new Promise((resolve) => + provider.listen(0, '127.0.0.1', resolve), + ); + const { port } = provider.address() as import('node:net').AddressInfo; + const bridge = makeRuntimeBridge(); + const createBridge = vi + .spyOn(acpBridge, 'createAcpSessionBridge') + .mockReturnValue( + bridge as ReturnType, + ); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + externalToolGuard: { + mode: 'required', + endpoint: `http://127.0.0.1:${port}`, + token: 'guard-token', + }, + } as Parameters[0], + { resolveOnListen: true }, + ); + + try { + await handle.runtimeReady; + const bridgeOptions = createBridge.mock.calls[0]?.[0] as + | { + childEnvOverrides?: Record; + externalToolGuard?: unknown; + } + | undefined; + expect(bridgeOptions?.childEnvOverrides).toMatchObject({ + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD: 'required-v1', + QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER: 'attached-v1', + }); + expect(bridgeOptions?.externalToolGuard).toEqual(expect.any(Function)); + } finally { + await handle.close(); + await new Promise((resolve) => provider.close(() => resolve())); + } + }); + it('applies memoryProjectScope to every runtime without mutating process.env', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-memory-project-scope-')), From 338e0c769da68208e2d2fcea6fc288fe5caf5c12 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 06:02:43 +0800 Subject: [PATCH 15/45] fix(serve): close the round-3 shell and repository-discovery gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every payload below was reproduced against the real guard first; the two that turn on git's own behaviour were measured with git 2.47.3. Repository discovery - `ls-files` executes the target repository's `core.fsmonitor` — the exact property that removed `status` — so it leaves the relocated read-only set. Measured: `git -C ls-files` runs the hook; `rev-parse` and `cat-file` remain side-effect free. - The discovery check was tied to a *cwd* relocation, so a `--work-tree`-only or bare relocation skipped it. Git discovers its repository from the cwd whenever no `--git-dir` names one, so the check now runs on that basis, and an unrecognized program (`cd sub && nice git branch`) gets it too. A linked worktree whose own gitfile points at an outside admin directory stays allowed — pinned from both sides. Shell front-end - `eval > /dev/null '…'` swallowed the redirection into its payload and lost the command. Redirect operands (and an `N>` descriptor prefix) are now flagged: still scanned for markers, never joined into argv. - `cd` glued to a control operator (`true;cd `) was not a marker. - Letters after `c` in a short bundle are more flags, not a fused payload: `bash -cx 'cd && …'` and `sh -co ignoreeof '…'` took their real payload from a later argv entry the guard never read. - `( … )` is now scoped like a subshell: `(cd ); git commit` is allowed again, while `(cd && git reset --hard)` still denies. - `sudo -R `/`--chroot=` and a `PATH=`/`GIT_EXEC_PATH=` assignment make every path the daemon resolves meaningless, so they fail closed. - `env --unset=NAME`, `-uNAME` and `--split-string=` in their attached forms no longer read as unrecognized options, which was denying decidable commands. - Values assigned earlier in the same command are substituted before the dynamic-program check (`X=git; Y='-C …'; $X $Y`), `eval` carries its shell locals back out, and `export $NAME` fails closed. - A command that relinks a path (`ln`, `mv`) invalidates containment proved afterwards: `ln -s bait && git -C bait reset --hard` is checked while `bait` is still the original directory. - `resolvePhysicalPath` treated `\` as a separator on POSIX, where it is an ordinary filename character. - `gpg..program` and `core.hooksPath` join the command-executing config keys. --- docs/design/daemon-git-worktree-guard.md | 7 +- docs/users/qwen-serve.md | 2 +- .../serve/daemon-git-worktree-guard.test.ts | 146 ++++++++- .../src/serve/daemon-git-worktree-guard.ts | 304 ++++++++++++++---- 4 files changed, 391 insertions(+), 68 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index 618e376f30f..c79effc7631 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -104,11 +104,12 @@ hold: 2. its Git subcommand is mutating or cannot be classified as read-only. Relocated commands whose subcommand is in a small verified read-only set -(`rev-parse`, `ls-files`, `cat-file`) remain allowed. `diff`, +(`rev-parse`, `cat-file`) remain allowed. `diff`, `log`, `show`, and `blame` are excluded from that set: `--output` writes files, and textconv-style drivers execute programs configured by the target -repository. `grep` takes the same `--textconv` path, `status` refreshes -the target index and runs the target repository's `core.fsmonitor`, and +repository. `grep` takes the same `--textconv` path, `status` and `ls-files` both run the +target repository's `core.fsmonitor` (`ls-files` executes the hook even +though it writes no index), and `describe --dirty`/`--broken` rewrite the target index whenever its stat cache is stale — a plain `describe` does not, but the flag is one token away — so none of them is read-only here. A `--output`, `--textconv`, or `--filters` flag diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index e6553ef12de..f7bd8811142 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -465,7 +465,7 @@ target that cannot be fully resolved before execution — a dynamic target (`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an unreadable indirection — is denied for mutating or unclassifiable subcommands. Relocated commands whose subcommand is one of a small verified read-only set -(`rev-parse`, `ls-files`, `cat-file`) remain allowed, unless the +(`rev-parse`, `cat-file`) remain allowed, unless the target is unresolvable, the command carries command-executing `-c` config, or it carries a `--output`, `--textconv`, or `--filters` flag: those write a file or run the target repository's configured drivers. Commands with no recognized diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 5651e2191e2..13db0d0700a 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -551,16 +551,42 @@ it -C ${outsideRepo} reset --hard`, it('denies relocated mutations whose target does not exist at decision time', async () => { const guard = createDaemonToolGuard(); - // The command itself can create an outward symlink before git runs, so - // a target that is missing now cannot be proven safe. + // A target that is missing now cannot be proven safe: it may exist as an + // outward symlink by the time git runs. await expect( - guard(request(`ln -s ${outsideRepo} link && git -C link reset --hard`)), + guard(request('git -C not-created-yet reset --hard')), ).resolves.toMatchObject({ allowed: false, reason: expect.stringContaining('unresolvable repository location'), }); }); + // A path the command itself re-points defeats any containment proved before + // it runs — `bait` is still the original directory when the guard looks. + it.each([ + () => `ln -s ${outsideRepo} link && git -C link reset --hard`, + () => + `rm -rf nested && ln -s ${outsideRepo} nested && git -C nested reset --hard`, + () => `mv ${outsideRepo} nested && git -C nested reset --hard`, + ])( + 'denies a relocation after the command relinks a path %#', + async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves ordinary in-boundary Git alone after an unrelated copy', async () => { + const guard = createDaemonToolGuard(); + + await expect(guard(request('cp a b && git commit -m x'))).resolves.toEqual({ + allowed: true, + }); + }); + it('follows gitfile redirects before the containment check', async () => { // Per-test fixture: the redirect file persists for the rest of the run // and would change how later tests resolve targets under a shared basis. @@ -848,7 +874,8 @@ it -C ${outsideRepo} reset --hard`, () => `git -C ${outsideRepo} cat-file --textconv --path=f.txt HEAD:f.txt`, () => `git -C ${outsideRepo} cat-file --filters --path=f.txt HEAD:f.txt`, () => `git -C ${outsideRepo} rev-parse --output=${outsideRepo}/o.txt HEAD`, - () => `git -C ${outsideRepo} ls-files --output ${outsideRepo}/o.txt`, + () => + `git -C ${outsideRepo} cat-file --output ${outsideRepo}/o.txt -p HEAD`, ])( 'denies a relocated read-only subcommand carrying a disqualifying flag %#', async (buildCommand) => { @@ -866,13 +893,26 @@ it -C ${outsideRepo} reset --hard`, for (const command of [ `git -C ${outsideRepo} cat-file -p HEAD:f.txt`, - `git -C ${outsideRepo} ls-files`, `git -C ${outsideRepo} rev-parse HEAD`, ]) { await expect(guard(request(command))).resolves.toEqual({ allowed: true }); } }); + // `ls-files` executes the target repository's core.fsmonitor hook — the + // same property that excluded `status` (measured on git 2.47.3). + it.each([ + () => `git -C ${outsideRepo} ls-files`, + () => `git -C ${outsideRepo} ls-files --others`, + ])('denies a relocated ls-files %#', async (buildCommand) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(buildCommand()))).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining(outsideRepo), + }); + }); + // `describe --dirty`/`--broken` rewrite the target repository's index // whenever its stat cache is stale (measured on git 2.47.3: a plain // `describe`/`--tags`/`--always` leaves .git/index untouched). The whole @@ -1133,6 +1173,102 @@ it -C ${outsideRepo} reset --hard`, }); }); + // A redirection operand is scanned for markers but is never argv, so an + // `eval` payload must not absorb it. + it.each([ + () => `eval > /dev/null 'cd ${outsideRepo} && git reset --hard'`, + () => `eval 2> /dev/null 'cd ${outsideRepo} && git reset --hard'`, + ])('keeps redirections out of an eval payload %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // `cd` glued to a control operator is still a relocation. + it.each([ + () => `su -c 'true;cd ${outsideRepo} && git reset --hard'`, + () => `su -c 'true&&cd ${outsideRepo} && git reset --hard'`, + ])('treats an operator-glued cd as a marker %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Letters after `c` in a bundle are more flags; the payload is a later argv + // entry, and `-o`/`-O` among them consumes one first. + it.each([ + () => `bash -cx 'cd ${outsideRepo} && git reset --hard'`, + () => `sh -co ignoreeof 'cd ${outsideRepo} && git reset --hard'`, + () => `bash -c'cd ${outsideRepo} && git reset --hard'`, + ])('reads the -c payload from the right argv entry %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it.each([ + // `sudo -R ` moves the filesystem root out from under every path. + () => `sudo -R ${outsideRepo} git reset --hard`, + () => `sudo --chroot=${outsideRepo} git reset --hard`, + // A command that chooses its own `git` binary defeats the classification. + () => `PATH=/tmp/evilbin git commit -m x`, + () => `GIT_EXEC_PATH=/tmp/evil git commit -m x`, + ])( + 'fails closed when the run redefines its own context %#', + async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + // Values assigned earlier in the same command are visible to the guard. + it.each([ + () => `X='git reset --hard'; cd ${outsideRepo}; $X`, + () => `X=git; Y='-C ${outsideRepo} reset --hard'; $X $Y`, + () => + `eval 'GIT_WORK_TREE=${outsideRepo}'; export GIT_WORK_TREE; git reset --hard`, + () => `GIT_WORK_TREE=${outsideRepo}; export $NAME; git reset --hard`, + ])('resolves a relocation through shell variables %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('scopes a parenthesized subshell the way the shell does', async () => { + const guard = createDaemonToolGuard(); + + // The subshell's cwd dies with its parentheses... + await expect( + guard(request(`(cd ${outsideRepo}); git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + // ...but a Git command inside them is still judged against it. + await expect( + guard(request(`(cd ${outsideRepo} && git reset --hard)`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it('keeps env value flags in their attached forms decidable', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'env --unset=GIT_DIR git commit -m x', + 'env -uGIT_DIR git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 8e0fa855800..8df783d38c2 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -27,16 +27,13 @@ import type { // execute programs configured by the target repository on the managed // (non-tty) output path. `diff`/`log`/`show`/`blame` are excluded: `--output` // writes files and textconv drivers run commands from target-repository -// config. `grep` takes the same `--textconv` path, `status` refreshes the -// target index and runs the target repository's core.fsmonitor, and +// config. `grep` takes the same `--textconv` path; `status` and `ls-files` +// both run the target repository's core.fsmonitor (measured on git 2.47.3 — +// `ls-files` executes the hook even though it writes no index); and // `describe --dirty`/`--broken` rewrite the target index whenever its stat -// cache is stale (measured on git 2.47.3; a plain `describe` does not, but -// the flag is one token away), so none of them is read-only here. -const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set([ - 'cat-file', - 'ls-files', - 'rev-parse', -]); +// cache is stale (a plain `describe` does not, but the flag is one token +// away), so none of them is read-only here. +const RELOCATED_READ_ONLY_GIT_SUBCOMMANDS = new Set(['cat-file', 'rev-parse']); // Flags that break the invariant above wherever they appear: `--output` // writes a file, and `--textconv`/`--filters` run the *target* repository's @@ -74,7 +71,8 @@ const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ /^diff\..+\.(command|textconv)$/, /^difftool\./, /^filter\./, - /^gpg\.program$/, + /^core\.hookspath$/, + /^gpg\.(.+\.)?program$/, /^merge\..+\.driver$/, /^mergetool\./, /^pager\./, @@ -128,9 +126,18 @@ const SUDO_VALUE_FLAGS = new Set([ '--user', ]); const SUDO_CHDIR_FLAGS = new Set(['-D', '--chdir']); +// `sudo -R ` runs the command under a different filesystem root, so +// no path the daemon resolves means what git will see. +const SUDO_CHROOT_FLAGS = new Set(['-R', '--chroot']); const TIMEOUT_VALUE_FLAGS = new Set(['-k', '-s', '--kill-after', '--signal']); +// Programs that can point an existing in-boundary path at somewhere else. +// Running one earlier in the same command invalidates any containment the +// guard proves afterwards: `ln -s bait && git -C bait reset --hard` +// is checked while `bait` is still the original directory. +const PATH_RELINKING_PROGRAMS = new Set(['ln', 'mv']); + // Pinned to ToolNames.AGENT/WORKFLOW/CREATE_SUB_SESSION/SEND_MESSAGE in // @qwen-code/qwen-code-core. The literals keep this module free of a core // barrel import for this one set; daemon-git-worktree-guard.test.ts asserts @@ -167,6 +174,10 @@ interface TrustedDaemonToolGuardRequest interface GuardToken { readonly text: string; readonly dynamic: boolean; + // Operand of a redirection (`> out`, `<<< payload`). It is scanned for + // relocation markers — a here-string carries a whole command — but it is + // never argv, so payload joins (`eval …`, `env -S …`) must skip it. + readonly redirect?: boolean; } interface GitEnvRelocation { @@ -256,17 +267,37 @@ const REDIRECT_OPERATORS = new Set([ '&>>', ]); -function tokenizeSegment(segment: string): GuardToken[][] | null { +interface GuardRun { + readonly tokens: GuardToken[]; + // `( … )` nesting level. A subshell's `cd` does not outlive its parentheses. + readonly depth: number; +} + +interface TokenizedSegment { + readonly runs: GuardRun[]; + // `splitCommands` cuts on `&&`/`;` without regard for parentheses, so the + // paren nesting has to be carried from one segment to the next. + readonly endDepth: number; +} + +function tokenizeSegment( + segment: string, + startDepth: number, +): TokenizedSegment | null { let parsed: ReturnType; try { parsed = parse(segment, (key) => `$${key}`); } catch { return null; } - const runs: GuardToken[][] = [[]]; + let depth = startDepth; + const runs: GuardRun[] = [{ tokens: [], depth }]; + let redirectOperand = false; for (let index = 0; index < parsed.length; index++) { const token = parsed[index]; if (typeof token === 'string') { + const isRedirectOperand = redirectOperand; + redirectOperand = false; // A `$(...)` substitution arrives as a string ending in `$` followed // by an `(` operator. Consume the whole body as one opaque dynamic // token so the assignment/flag it belongs to keeps its place instead @@ -291,13 +322,18 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { } } } - runs.at(-1)!.push({ text: token, dynamic: true }); + runs.at(-1)!.tokens.push({ + text: token, + dynamic: true, + ...(isRedirectOperand ? { redirect: true } : {}), + }); continue; } } - runs.at(-1)!.push({ + runs.at(-1)!.tokens.push({ text: token, dynamic: token.includes('$') || token.includes('`'), + ...(isRedirectOperand ? { redirect: true } : {}), }); continue; } @@ -312,26 +348,63 @@ function tokenizeSegment(segment: string): GuardToken[][] | null { 'pattern' in token && typeof token.pattern === 'string' ? token.pattern : ''; - runs.at(-1)!.push({ text: pattern, dynamic: true }); + runs.at(-1)!.tokens.push({ text: pattern, dynamic: true }); continue; } if (op === '(' || op === ')') { - runs.push([]); + depth = op === '(' ? depth + 1 : Math.max(0, depth - 1); + runs.push({ tokens: [], depth }); continue; } if (REDIRECT_OPERATORS.has(op)) { - // The operand stays in the run: a here-string (`sh <<< 'git -C … reset - // --hard'`) carries an executable payload, and an ordinary redirect - // target is inert text that no analysis step acts on. + // The operand stays in the run — a here-string (`sh <<< 'git -C … reset + // --hard'`) carries an executable payload — but it is flagged so no + // payload join mistakes it for argv. An `N>` file descriptor prefix is + // part of the redirection too, never a word of the command. + const tokens = runs.at(-1)!.tokens; + const previous = tokens.at(-1); + if (previous && !previous.redirect && /^\d+$/.test(previous.text)) { + tokens[tokens.length - 1] = { ...previous, redirect: true }; + } + redirectOperand = true; continue; } - runs.push([]); + runs.push({ tokens: [], depth }); } - return runs.filter((run) => run.length > 0); + return { runs: runs.filter((run) => run.tokens.length > 0), endDepth: depth }; +} + +/** + * Substitute `$NAME`/`${NAME}` from assignments made earlier in this same + * command. `X=git; Y='-C reset --hard'; $X $Y` is a relocation the + * literal token scan cannot see, but the values are right there. + */ +function expandShellLocals( + token: GuardToken, + shellLocals: ReadonlyMap, +): GuardToken { + if (!token.dynamic || shellLocals.size === 0) return token; + let resolved = true; + const text = token.text.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, + (match, name: string) => { + const local = shellLocals.get(name); + if (local === undefined || local.dynamic) { + resolved = false; + return match; + } + return local.text.slice(local.text.indexOf('=') + 1); + }, + ); + if (text === token.text) return token; + return { text, dynamic: !resolved }; } function joinTokenTexts(tokens: GuardToken[]): string { - return tokens.map((token) => token.text).join(' '); + return tokens + .filter((token) => !token.redirect) + .map((token) => token.text) + .join(' '); } function hasGitRelocationMarker(tokens: GuardToken[]): boolean { @@ -356,11 +429,20 @@ const GIT_WORD_PATTERN = /\bgit\b/i; // A `cd`/`pushd` inside such a payload relocates the git that follows it just // as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). const TEXT_RELOCATION_MARKER_PATTERN = - /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|\s)(cd|pushd)(\s|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + +// Assignments that decide WHICH git binary the run executes. The guard +// classifies the program word `git` and then reasons about paths; if the +// binary itself is chosen by the command, that reasoning proves nothing. +const GIT_PROGRAM_ENV_KEYS = new Set(['GIT_EXEC_PATH', 'PATH']); function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; + if (GIT_PROGRAM_ENV_KEYS.has(key)) { + state.unresolved = true; + return; + } if (!GIT_DIR_ENV_KEYS.has(key) && !GIT_WORK_TREE_ENV_KEYS.has(key)) return; const value = token.text.slice(token.text.indexOf('=') + 1); if ( @@ -476,6 +558,21 @@ function consumeEnvWrapper( index += 2; continue; } + // `env -uNAME` / `--unset=NAME` carry their value in the same token, so + // they consume nothing further and must not look unrecognized. + if ( + /^-u./.test(token.text) || + token.text.startsWith('--unset=') || + token.text.startsWith('--split-string=') + ) { + if (token.text.startsWith('--split-string=')) { + const fused = token.text.slice('--split-string='.length); + const rest = joinTokenTexts(run.slice(index + 1)); + return { next: run.length, payload: rest ? `${fused} ${rest}` : fused }; + } + index++; + continue; + } if (leadingEnvAssignmentKey(token.text) !== null) { recordEnvAssignment(token, state); index++; @@ -517,6 +614,15 @@ function consumeSudoWrapper( index += 2; continue; } + if ( + SUDO_CHROOT_FLAGS.has(token.text) || + token.text.startsWith('--chroot=') || + /^-R./.test(token.text) + ) { + state.unresolved = true; + index += SUDO_CHROOT_FLAGS.has(token.text) ? 2 : 1; + continue; + } const attached = attachedChdirValue(token.text, SUDO_CHDIR_FLAGS); if (attached !== undefined) { recordChdirValue({ text: attached, dynamic: false }, state); @@ -576,15 +682,16 @@ function consumeShellWrapper( : { kind: 'none' }; } if (shellBundleRequestsCommand(token.text)) { - const fusedPayload = token.text.slice(token.text.indexOf('c') + 1); - if (fusedPayload.length > 0) { - // `bash -c'cmd'`: the payload is fused into the flag token after - // the `c`, not the next argv entry. - return { kind: 'static', payload: fusedPayload }; + const remainder = token.text.slice(token.text.indexOf('c') + 1); + // A remainder of nothing but letters is more short options (`-cx`, + // `-co`), which POSIX shells parse as flags and then take the payload + // from a later argv entry — `-o`/`-O` among them consumes one first. + // Anything else is a payload fused into the token (`bash -c'cmd'`); a + // pure-letter remainder cannot hide a relocation either way. + if (remainder.length > 0 && !/^[A-Za-z]+$/.test(remainder)) { + return { kind: 'static', payload: remainder }; } - // `bash -lc 'cmd'`: `c` ends the bundle, so the payload is the next - // argv entry after all. - const payloadToken = run[index + 1]; + const payloadToken = run[index + (/[oO]/.test(remainder) ? 2 : 1)]; if (payloadToken === undefined) { return { kind: 'static', payload: '' }; } @@ -1014,7 +1121,8 @@ async function resolvePhysicalPath( target: string, ): Promise { let current = path.isAbsolute(target) ? path.parse(target).root : base; - for (const segment of target.split(/[\\/]+/)) { + const separators = path.sep === '\\' ? /[\\/]+/ : /\/+/; + for (const segment of target.split(separators)) { if (segment === '' || segment === '.') continue; if (segment === '..') { current = path.dirname(await realpathNearestExistingAsync(current)); @@ -1181,26 +1289,37 @@ async function evaluateGitInvocation( if (!isWithinRoot(repositoryTarget, context.canonicalEffectiveCwd)) { return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, repositoryTarget); } - // The directory git runs in is inside the boundary, but the repository it - // discovers from there may not be. - if (kind === 'cwd') { - let discovered: string | undefined; - try { - discovered = await resolveDiscoveredRepository( - repositoryTarget, - context.canonicalEffectiveCwd, - ); - } catch { - return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, repositoryTarget); - } - if (discovered !== undefined) { - const canonicalDiscovered = - await realpathNearestExistingAsync(discovered); - if (!isWithinRoot(canonicalDiscovered, context.canonicalEffectiveCwd)) { - return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalDiscovered); - } - } - } + } + // Unless a `--git-dir`/`GIT_DIR` names the repository outright, git finds it + // by walking up from its working directory — which can hand it a repository + // outside the boundary even when the directory itself is inside. + if ( + repositoryRelocations.every((relocation) => relocation.kind !== 'git-dir') + ) { + const denial = await denyOutsideDiscoveredRepository(gitCwd, context); + if (denial) return denial; + } + return undefined; +} + +async function denyOutsideDiscoveredRepository( + startDirectory: string, + context: GuardEvaluationContext, +): Promise { + const canonicalStart = await realpathNearestExistingAsync(startDirectory); + let discovered: string | undefined; + try { + discovered = await resolveDiscoveredRepository( + canonicalStart, + context.canonicalEffectiveCwd, + ); + } catch { + return denyTarget(UNRESOLVED_TARGET_DENIAL_PREFIX, canonicalStart); + } + if (discovered === undefined) return undefined; + const canonicalDiscovered = await realpathNearestExistingAsync(discovered); + if (!isWithinRoot(canonicalDiscovered, context.canonicalEffectiveCwd)) { + return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalDiscovered); } return undefined; } @@ -1312,12 +1431,13 @@ async function evaluateUnrecognizedRun( if (basisCwd === undefined) { return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; } - if (basisCwd === entryCwd) return undefined; const canonicalBasis = await realpathNearestExistingAsync(basisCwd); if (!isWithinRoot(canonicalBasis, context.canonicalEffectiveCwd)) { return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, canonicalBasis); } - return undefined; + // Same discovery rule as a recognized git run: being in an in-boundary + // directory says nothing about which repository git finds from it. + return denyOutsideDiscoveredRepository(canonicalBasis, context); } interface CommandEvaluation { @@ -1327,6 +1447,7 @@ interface CommandEvaluation { // in the current shell (`eval`) propagates it back to the caller. readonly exportedAfter?: PrefixState; readonly allExportAfter?: boolean; + readonly shellLocalsAfter?: ReadonlyMap; } async function evaluateCommandWithCwd( @@ -1344,6 +1465,8 @@ async function evaluateCommandWithCwd( // GIT_* assignments made without `export`. They stay shell-local until a // name-only `export GIT_DIR` promotes them into the environment. const shellLocals = new Map(); + // Set once a run in this command can have re-pointed an existing path. + let relinkedPaths = false; // Exported relocations reach every later command, including the ones nested // inside a wrapper payload or a substitution body. const activeContext = (): GuardEvaluationContext => @@ -1357,9 +1480,13 @@ async function evaluateCommandWithCwd( ambientUnresolved: context.ambientUnresolved || exported.unresolved, } : context; + let subshellDepth = 0; + const subshellCwds: Array = []; for (const segment of splitCommands(command)) { const substitutions = extractCommandSubstitutions(segment); - const runs = substitutions === null ? null : tokenizeSegment(segment); + const tokenized = + substitutions === null ? null : tokenizeSegment(segment, subshellDepth); + const runs = tokenized?.runs ?? null; if (runs === null) { return { denial: { allowed: false, reason: UNPARSEABLE_COMMAND_DENIAL }, @@ -1383,7 +1510,16 @@ async function evaluateCommandWithCwd( return { denial: nested.denial, cwdAfter: trackedCwd }; } } - for (const run of runs) { + for (const { tokens: run, depth: runDepth } of runs) { + while (runDepth > subshellDepth) { + subshellCwds.push(trackedCwd); + subshellDepth++; + } + while (runDepth < subshellDepth) { + // Leaving `( … )`: the subshell's cwd changes die with it. + trackedCwd = subshellCwds.pop(); + subshellDepth--; + } const analysis = analyzeRun(run); switch (analysis.kind) { case 'cd': { @@ -1454,11 +1590,21 @@ async function evaluateCommandWithCwd( if (nested.exportedAfter.unresolved) exported.unresolved = true; } if (nested.allExportAfter) allExport = true; + for (const [key, token] of nested.shellLocalsAfter ?? []) { + shellLocals.set(key, token); + } } break; } case 'git': { const invocation = readGitInvocation(analysis.tokens); + if (relinkedPaths && analysis.tokens.length > 1) { + // Anything this command relinked defeats a path check made now. + return { + denial: denyDynamicRelocation(), + cwdAfter: trackedCwd, + }; + } const denial = await evaluateGitInvocation( invocation, analysis.state, @@ -1471,19 +1617,36 @@ async function evaluateCommandWithCwd( } case 'dynamic-program': { const inherited = activeContext(); + const expanded = analysis.rest.map((token) => + expandShellLocals(token, shellLocals), + ); if ( analysis.state.unresolved || analysis.state.relocations.length > 0 || inherited.ambientUnresolved || inherited.ambientRelocations.length > 0 || - hasGitRelocationMarker(analysis.rest) + hasGitRelocationMarker(expanded) || + expanded.some((token) => + TEXT_RELOCATION_MARKER_PATTERN.test(token.text), + ) ) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; } // A program word the daemon cannot read is at least as opaque as an - // unrecognized one, so it answers to the same containment rule. + // unrecognized one, so it answers to the same containment rule — + // and when the shell has already left the boundary, an unreadable + // program word is undecidable rather than harmless. + if ( + trackedCwd === undefined || + !isWithinRoot( + await realpathNearestExistingAsync(trackedCwd), + inherited.canonicalEffectiveCwd, + ) + ) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } const denial = await evaluateUnrecognizedRun( - analysis.rest, + expanded, analysis.state, trackedCwd, entryCwd, @@ -1504,6 +1667,11 @@ async function evaluateCommandWithCwd( // shell-local assignment left in that name. for (const operand of analysis.operands) { if (leadingEnvAssignmentKey(operand.text) !== null) continue; + if (operand.dynamic) { + // `export $NAME` can promote any assignment made earlier. + exported.unresolved = true; + continue; + } const pending = shellLocals.get(operand.text); if (pending) recordEnvAssignment(pending, exported); } @@ -1521,6 +1689,12 @@ async function evaluateCommandWithCwd( allExport = true; break; case 'other': { + if ( + run.length > 0 && + PATH_RELINKING_PROGRAMS.has(executableBaseName(run[0]!)) + ) { + relinkedPaths = true; + } if (analysis.assignmentsOnly) { if (allExport) { // `set -a` turned this shell-local assignment into an exported @@ -1551,11 +1725,23 @@ async function evaluateCommandWithCwd( } } } + // The tokenizer's closing depth is authoritative for what this segment + // did with parentheses: `(cd )` opens and closes within it, so + // the subshell's cwd must not survive into the next segment. + while (tokenized!.endDepth < subshellDepth) { + trackedCwd = subshellCwds.pop(); + subshellDepth--; + } + while (tokenized!.endDepth > subshellDepth) { + subshellCwds.push(trackedCwd); + subshellDepth++; + } } return { cwdAfter: trackedCwd, exportedAfter: exported, allExportAfter: allExport, + shellLocalsAfter: shellLocals, }; } From d9a3911bcb640b4059e210533f5d180ad4e4199d Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 06:08:11 +0800 Subject: [PATCH 16/45] fix(serve): scope the relink invalidation to path-resolving Git runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule I added a commit ago denied every Git run that followed an `ln` or `mv` in the same command, which takes out `mv old new && git add -A` — as ordinary as it gets. The invalidation only makes sense for an invocation that resolves a path, so it now requires a relocation target or a shifted cwd: `ln -s bait && git -C bait reset --hard` still denies, while staging renamed files does not. --- .../src/serve/daemon-git-worktree-guard.test.ts | 15 +++++++++++---- .../cli/src/serve/daemon-git-worktree-guard.ts | 12 ++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 13db0d0700a..bdea6376805 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -579,12 +579,19 @@ it -C ${outsideRepo} reset --hard`, }, ); - it('leaves ordinary in-boundary Git alone after an unrelated copy', async () => { + // Only an invocation that resolves a path is affected: renaming files and + // then staging them is everyday work, not a relocation. + it('leaves path-free Git alone after a rename', async () => { const guard = createDaemonToolGuard(); - await expect(guard(request('cp a b && git commit -m x'))).resolves.toEqual({ - allowed: true, - }); + for (const command of [ + 'cp a b && git commit -m x', + 'mv old new && git add -A', + 'mv old new && git add -A && git commit -m x', + 'ln -s a b && git status', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } }); it('follows gitfile redirects before the containment check', async () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 8df783d38c2..1c741a824d2 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1598,8 +1598,16 @@ async function evaluateCommandWithCwd( } case 'git': { const invocation = readGitInvocation(analysis.tokens); - if (relinkedPaths && analysis.tokens.length > 1) { - // Anything this command relinked defeats a path check made now. + // A path this command relinked defeats a containment check made + // afterwards — but only for an invocation that resolves a path. + // `mv old new && git add -A` targets nothing and stays allowed. + if ( + relinkedPaths && + (invocation.cwdTargets.length > 0 || + invocation.gitDirTargets.length > 0 || + invocation.workTreeTargets.length > 0 || + trackedCwd !== entryCwd) + ) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd, From 012d2528b24f05d75d44259015eb4adad42af29b Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 14:08:51 +0800 Subject: [PATCH 17/45] fix(serve): rebuild the relink defense and close the round-4 gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced against the real guard first; the two environment claims were measured with git 2.47.3. Fixing my own two previous commits - The relink invalidation was both too wide and too narrow. It now records which paths a run may have re-pointed instead of setting one flag: a relinked `.git` invalidates repository discovery for every later command (`ln -s /.git .git && git status` mutated the outside repo), while a relinked directory only affects a run that resolves that very path, so `mv old new && git add -A` stays allowed. The scan no longer keys on `run[0]`, which `env ln …`, `X=1 ln …` and `nice ln …` walked straight past, and `cp -s` joins `ln`/`mv`. - Flagging redirect operands (the here-string fix) left `consumeShellWrapper` reading one as the `-c` payload: `sh -c > /dev/null 'git -C …'` dropped the real payload from analysis. - Leaving a subshell rolled back only the tracked cwd, so exports and shell locals made inside `( … )` kept denying later commands. - Shell-local reconstruction was last-assignment-wins, losing `X+=` appends. Repository discovery - A non-relocated Git command never reached discovery, so a planted `.git` gitfile at the session root redirected plain `git commit` outside. Discovery now runs for those too; a session bound below its repository is unaffected because the walk stops at the boundary, and a linked worktree still resolves back to its own checkout — both pinned. Environment and parsing - `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_CONFIG*` and `SHELLOPTS` mark the invocation unresolved. Measured: `GIT_OBJECT_DIRECTORY=/.git/objects git add` writes the blob there, and `GIT_CONFIG_GLOBAL=/cfg` makes git read that config. - `$'…'` is ANSI-C quoting where a backslash escapes, so `$'a\'b'` no longer leaves the substitution scanner a quote out of phase — which had hidden a following `$(git -C …)` from every analysis pass. Over-denial - A program with its own `-C` (`grep -C 5 git`, `tar -C dir`) no longer reads as a Git relocation. --- docs/design/daemon-git-worktree-guard.md | 29 ++- docs/users/qwen-serve.md | 8 +- .../serve/daemon-git-worktree-guard.test.ts | 97 +++++++++ .../src/serve/daemon-git-worktree-guard.ts | 206 +++++++++++++++--- 4 files changed, 303 insertions(+), 37 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index c79effc7631..c1c94bc6b77 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -35,7 +35,7 @@ changed by literal forms of: - `git --git-dir ` and `git --git-dir=` - leading `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, or `GIT_INDEX_FILE` assignments -- the same assignments made through `export`/`declare`/`typeset`/`readonly` +- the same assignments made through `export`/`declare`/`typeset`/`readonly`/`local` (or plain assignments under `set -a`), which stay in the environment of every later command in the same chain rather than only their own run. A name-only `export GIT_DIR` exports the value an earlier shell-local @@ -56,8 +56,9 @@ containment basis so a preceding `cd` cannot disappear inside the wrapper), and leading shell keywords and reserved words (`{`, `}`, `!`, `if`, `then`, `else`, `elif`, `fi`, `for`, `do`, `done`, `while`, `until`, `in`, `case`, `esac`, `time`, `coproc`), which can lead a split segment without changing -what executes. `cd`/`pushd` option words (`-L`, `-P`, `-e`, `-@`, `--`) are -skipped when locating the directory operand, so containment is evaluated +what executes. `cd` option words (`-L`, `-P`, `-e`, `-@`, `-q`, `-s`, `--`) are +skipped when locating the directory operand — `pushd`/`popd` treat any +leading `-`/`+` word as unresolvable instead, so containment is evaluated against the directory the shell actually enters. A segment whose program token cannot be classified — including one the daemon cannot read at all (`$CMD`) — fails closed when the segment still references Git and @@ -128,8 +129,18 @@ directory. The command-executing keys are `alias.*`, `core.askPass`, `credential.helper`, `diff..command`, `diff..textconv`, `difftool.*`, `filter.*`, `gpg.program`, `merge..driver`, `mergetool.*`, `pager.*`, `sequence.editor`, and -`uploadpack.packObjectsHook`, matched case-insensitively because Git config -keys are; any value starting with `!` counts too. +`uploadpack.packObjectsHook`, `core.hooksPath` and `gpg..program`, +matched case-insensitively because Git config keys are; any value starting +with `!` counts too. The check runs before the read-only allowance and +independently of relocation, so such a `-c` is denied even in the session's +own repository. + +`GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_CONFIG`, +`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM` and `SHELLOPTS` name no repository +the containment check can resolve but do move where git writes or which +config it reads (measured: `GIT_OBJECT_DIRECTORY=/.git/objects git +add` writes the blob there), so they mark the invocation unresolved. So do +`PATH`/`GIT_EXEC_PATH`, which decide which `git` binary runs at all. Git global options that consume the next argv entry (`--namespace`, `--super-prefix`, `--shallow-file`, `--attr-source`) are modelled as such: @@ -198,7 +209,9 @@ execution semantics. later in the same chain do not clear an exported GIT\_\* relocation, so such a chain can be denied even though the real shell would run it inside the session (a fail-closed false positive, not a bypass). -- No heredoc body analysis: Git-shaped text inside a heredoc is scanned as - executable lines and can be denied even though the shell never executes it - (a fail-closed false positive, not a bypass). +- No heredoc body analysis: `splitCommands` has no heredoc state, so a + heredoc body is scanned as ordinary command lines. Usually that only + over-denies (Git-shaped text the shell merely writes to a file), but the + direction is not guaranteed — a body can also shift the parse — so treat it + as unanalyzed rather than as fail-closed. - No attempt to correlate a denial with a previous tool call. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index f7bd8811142..473eb548067 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -464,9 +464,11 @@ redirects, symlinks, and per-worktree administrative directories. A relocated target that cannot be fully resolved before execution — a dynamic target (`$VAR`, backticks, `~`, globs), a path that does not exist yet, or an unreadable indirection — is denied for mutating or unclassifiable subcommands. -Relocated commands whose subcommand is one of a small verified read-only set -(`rev-parse`, `cat-file`) remain allowed, unless the -target is unresolvable, the command carries command-executing `-c` config, or +A relocated target that cannot be resolved is denied whatever the subcommand +is — including the read-only ones. Relocated commands whose subcommand is one +of a small verified read-only set (`rev-parse`, `cat-file`) remain allowed +once the target resolves, unless the command carries command-executing `-c` +config, or it carries a `--output`, `--textconv`, or `--filters` flag: those write a file or run the target repository's configured drivers. Commands with no recognized relocation keep their existing behavior. diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index bdea6376805..f6efca80b15 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1276,6 +1276,103 @@ it -C ${outsideRepo} reset --hard`, } }); + // A relinked `.git` redirects repository discovery for every later command, + // relocated or not; a relinked directory only affects a run that resolves + // that very path. + it.each([ + () => `ln -s ${path.join(outsideRepo, '.git')} .git && git status`, + () => `ln -s ${path.join(outsideRepo, '.git')} .git && git commit -m x`, + () => `env ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `X=1 ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `nice ln -s ${outsideRepo} bait && git -C bait reset --hard`, + () => `cp -s ${outsideRepo} bait && git -C bait reset --hard`, + ])('denies Git after the command relinks its path %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Git discovers its repository by walking up even with no relocation. + it('denies a planted gitfile at the session root', async () => { + const decoyRoot = path.join(temporaryRoot, 'decoy-session'); + await mkdir(decoyRoot, { recursive: true }); + await writeFile( + path.join(decoyRoot, '.git'), + `gitdir: ${path.join(outsideRepo, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + const call = { + ...request('git commit -m x'), + effectiveCwd: decoyRoot, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toMatchObject({ allowed: false }); + }); + + it('leaves a session bound below its repository alone', async () => { + // The repository's `.git` lives ABOVE the boundary, so the walk stops at + // the boundary and finds nothing — the ordinary monorepo-subdir session. + const repoRoot = path.join(temporaryRoot, 'mono'); + const session = path.join(repoRoot, 'packages', 'app'); + await mkdir(path.join(repoRoot, '.git'), { recursive: true }); + await mkdir(session, { recursive: true }); + + const guard = createDaemonToolGuard(); + const call = { + ...request('git commit -m x'), + effectiveCwd: session, + } as ExternalToolGuardPrepareRequest; + await expect(guard(call)).resolves.toEqual({ allowed: true }); + }); + + it.each([ + // Redirects and their `N>` prefixes are never the `-c` payload. + () => `sh -c > /dev/null 'git -C ${outsideRepo} reset --hard'`, + // These env keys move where git writes or which config it reads. + () => `GIT_OBJECT_DIRECTORY=${outsideRepo}/.git/objects git commit -m x`, + () => `GIT_CONFIG_GLOBAL=${outsideRepo}/evil.cfg git commit -m x`, + () => `GIT_ALTERNATE_OBJECT_DIRECTORIES=${outsideRepo} git commit -m x`, + // `$'…'` escapes with a backslash, so the scanner must not lose phase. + () => `echo $'a\\'b' $(git -C ${outsideRepo} reset --hard)`, + // `+=` builds the value the shell will expand. + () => `X=git; X+=' -C ${outsideRepo}'; X+=' reset --hard'; $X`, + ])('closes the round-4 shell and environment gaps %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps a subshell from leaking its environment outward', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(request(`(export GIT_WORK_TREE=${outsideRepo}); git commit -m x`)), + ).resolves.toEqual({ allowed: true }); + await expect( + guard(request(`(export GIT_WORK_TREE=${outsideRepo}; git reset --hard)`)), + ).resolves.toMatchObject({ allowed: false }); + }); + + it("leaves a program's own -C flag alone", async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'grep -C 5 git CHANGELOG.md', + 'tar -C nested -cf out.tar .', + 'diff -C 3 a.txt b.txt # git', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + // …while an unrecognized wrapper's -C is still git's. + await expect( + guard(request(`xargs -I{} git -C ${outsideRepo} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 1c741a824d2..9bca76b1982 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -84,6 +84,19 @@ const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ // core shell.ts GIT_ENV_SHIFTS_REPO). const GIT_DIR_ENV_KEYS = new Set(['GIT_COMMON_DIR', 'GIT_DIR']); const GIT_WORK_TREE_ENV_KEYS = new Set(['GIT_INDEX_FILE', 'GIT_WORK_TREE']); +// Keys that redirect where git writes or which config it reads without +// naming a repository the containment check can resolve. Measured on git +// 2.47.3: `GIT_OBJECT_DIRECTORY=/.git/objects git add` writes the +// blob there, and `GIT_CONFIG_GLOBAL=/cfg` makes git read that +// file — enough to point `core.hooksPath` outside. +const GIT_UNRESOLVABLE_ENV_KEYS = new Set([ + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_CONFIG', + 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_SYSTEM', + 'GIT_OBJECT_DIRECTORY', + 'SHELLOPTS', +]); const SHELL_WRAPPER_PROGRAMS = new Set(['bash', 'dash', 'ksh', 'sh', 'zsh']); const SHELL_WRAPPER_VALUE_FLAGS = new Set(['-o', '-O']); @@ -136,7 +149,22 @@ const TIMEOUT_VALUE_FLAGS = new Set(['-k', '-s', '--kill-after', '--signal']); // Running one earlier in the same command invalidates any containment the // guard proves afterwards: `ln -s bait && git -C bait reset --hard` // is checked while `bait` is still the original directory. -const PATH_RELINKING_PROGRAMS = new Set(['ln', 'mv']); +const PATH_RELINKING_PROGRAMS = new Set(['cp', 'ln', 'mv']); + +// Programs whose own `-C` means something else entirely (`grep -C 5`, +// `tar -C dir`), so it must not read as a git relocation marker. +const PROGRAMS_WITH_OWN_C_FLAG = new Set([ + 'cmake', + 'cpio', + 'diff', + 'grep', + 'install', + 'make', + 'patch', + 'rsync', + 'tar', + 'unzip', +]); // Pinned to ToolNames.AGENT/WORKFLOW/CREATE_SUB_SESSION/SEND_MESSAGE in // @qwen-code/qwen-code-core. The literals keep this module free of a core @@ -428,6 +456,8 @@ function hasGitRelocationMarker(tokens: GuardToken[]): boolean { const GIT_WORD_PATTERN = /\bgit\b/i; // A `cd`/`pushd` inside such a payload relocates the git that follows it just // as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). +const TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN = + /(^|\s)(--git-dir=?|--work-tree=?)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; const TEXT_RELOCATION_MARKER_PATTERN = /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; @@ -439,7 +469,7 @@ const GIT_PROGRAM_ENV_KEYS = new Set(['GIT_EXEC_PATH', 'PATH']); function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; - if (GIT_PROGRAM_ENV_KEYS.has(key)) { + if (GIT_PROGRAM_ENV_KEYS.has(key) || GIT_UNRESOLVABLE_ENV_KEYS.has(key)) { state.unresolved = true; return; } @@ -659,6 +689,14 @@ type ShellWrapperScan = | { kind: 'static'; payload: string } | { kind: 'dynamic' }; +// The next real argv entry: a redirection between the flag and its payload +// (`sh -c > /dev/null 'cmd'`) is not the payload. +function nextArgvIndex(run: GuardToken[], from: number): number { + let index = from; + while (index < run.length && run[index]!.redirect) index++; + return index; +} + function consumeShellWrapper( run: GuardToken[], start: number, @@ -667,7 +705,7 @@ function consumeShellWrapper( while (index < run.length) { const token = run[index]!; if (token.text === '-c') { - const payloadToken = run[index + 1]; + const payloadToken = run[nextArgvIndex(run, index + 1)]; if (payloadToken === undefined) { // `sh -c` with no payload executes nothing. return { kind: 'static', payload: '' }; @@ -691,7 +729,11 @@ function consumeShellWrapper( if (remainder.length > 0 && !/^[A-Za-z]+$/.test(remainder)) { return { kind: 'static', payload: remainder }; } - const payloadToken = run[index + (/[oO]/.test(remainder) ? 2 : 1)]; + let payloadIndex = nextArgvIndex(run, index + 1); + if (/[oO]/.test(remainder)) { + payloadIndex = nextArgvIndex(run, payloadIndex + 1); + } + const payloadToken = run[payloadIndex]; if (payloadToken === undefined) { return { kind: 'static', payload: '' }; } @@ -1228,7 +1270,15 @@ async function evaluateGitInvocation( basisCwd !== entryCwd || cwdRelocations.length > 0 || repositoryRelocations.length > 0; - if (!relocated) return undefined; + if (!relocated) { + // Even with no relocation git still discovers its repository by walking + // up from here, and a planted `.git` gitfile can point that walk outside. + // A session bound to a subdirectory of a repository is unaffected: its + // `.git` lives above the boundary and the walk stops at the boundary. + return basisCwd === undefined + ? undefined + : denyOutsideDiscoveredRepository(basisCwd, context); + } // `-C`, `env -C` and `sudo -D` all reach the kernel as a chdir, so each // component resolves through its symlinks before the next one applies. @@ -1331,6 +1381,19 @@ async function denyOutsideDiscoveredRepository( * --hard)`) has to be analysed rather than folded into an opaque token. * Returns null when a substitution is left unterminated. */ +// `$'…'` is ANSI-C quoting: unlike a plain single-quoted string, a backslash +// escapes inside it, so `$'a\'b'` does not end at the middle quote. Treating +// it as a plain quote leaves the scanner one quote out of phase and a later +// `$(…)` invisible. Returns the index just past the closing quote. +function skipAnsiCQuote(segment: string, start: number): number { + let index = start + 2; + while (index < segment.length && segment[index] !== "'") { + if (segment[index] === '\\') index++; + index++; + } + return index + 1; +} + function extractCommandSubstitutions(segment: string): string[] | null { const bodies: string[] = []; let single = false; @@ -1342,6 +1405,10 @@ function extractCommandSubstitutions(segment: string): string[] | null { index += 2; continue; } + if (!single && character === '$' && segment[index + 1] === "'") { + index = skipAnsiCQuote(segment, index); + continue; + } if (!single && character === '$' && segment[index + 1] === '(') { // `$((…))` is arithmetic, not a command. Stepping over the opening // punctuation keeps any real substitution nested inside it visible. @@ -1384,6 +1451,10 @@ function findSubstitutionEnd(segment: string, start: number): number { index++; continue; } + if (!single && character === '$' && segment[index + 1] === "'") { + index = skipAnsiCQuote(segment, index) - 1; + continue; + } if (character === "'" && !double) { single = !single; continue; @@ -1418,9 +1489,18 @@ async function evaluateUnrecognizedRun( context: GuardEvaluationContext, ): Promise { if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return undefined; + // `grep -C 5 git CHANGELOG.md` carries a `-C` that has nothing to do with + // git, so the program's own flag vocabulary decides whether it is a marker. + const ownsCFlag = + run.length > 0 && PROGRAMS_WITH_OWN_C_FLAG.has(executableBaseName(run[0]!)); if ( - hasGitRelocationMarker(run) || - run.some((token) => TEXT_RELOCATION_MARKER_PATTERN.test(token.text)) || + (!ownsCFlag && hasGitRelocationMarker(run)) || + run.some((token) => + (ownsCFlag + ? TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN + : TEXT_RELOCATION_MARKER_PATTERN + ).test(token.text), + ) || state.relocations.length > 0 || state.unresolved || context.ambientRelocations.length > 0 || @@ -1465,8 +1545,12 @@ async function evaluateCommandWithCwd( // GIT_* assignments made without `export`. They stay shell-local until a // name-only `export GIT_DIR` promotes them into the environment. const shellLocals = new Map(); - // Set once a run in this command can have re-pointed an existing path. - let relinkedPaths = false; + // Paths a run in this command may have re-pointed. Any containment the + // guard proves for one of them afterwards is proved against the old target. + const relinkedTargets: string[] = []; + // Set when a relinked path is (or may be) a `.git`, which redirects the + // repository discovery of every later command, relocated or not. + let relinkedGitDir = false; // Exported relocations reach every later command, including the ones nested // inside a wrapper payload or a substitution body. const activeContext = (): GuardEvaluationContext => @@ -1481,7 +1565,33 @@ async function evaluateCommandWithCwd( } : context; let subshellDepth = 0; - const subshellCwds: Array = []; + interface ShellStateSnapshot { + readonly cwd: string | undefined; + readonly relocations: GitEnvRelocation[]; + readonly unresolved: boolean; + readonly allExport: boolean; + readonly locals: Array<[string, GuardToken]>; + } + const snapshotShellState = (): ShellStateSnapshot => ({ + cwd: trackedCwd, + relocations: [...exported.relocations], + unresolved: exported.unresolved, + allExport, + locals: [...shellLocals], + }); + const restoreShellState = ( + snapshot: ShellStateSnapshot | undefined, + ): void => { + if (snapshot === undefined) return; + trackedCwd = snapshot.cwd; + exported.relocations.length = 0; + exported.relocations.push(...snapshot.relocations); + exported.unresolved = snapshot.unresolved; + allExport = snapshot.allExport; + shellLocals.clear(); + for (const [key, token] of snapshot.locals) shellLocals.set(key, token); + }; + const subshellCwds: ShellStateSnapshot[] = []; for (const segment of splitCommands(command)) { const substitutions = extractCommandSubstitutions(segment); const tokenized = @@ -1512,12 +1622,13 @@ async function evaluateCommandWithCwd( } for (const { tokens: run, depth: runDepth } of runs) { while (runDepth > subshellDepth) { - subshellCwds.push(trackedCwd); + subshellCwds.push(snapshotShellState()); subshellDepth++; } while (runDepth < subshellDepth) { - // Leaving `( … )`: the subshell's cwd changes die with it. - trackedCwd = subshellCwds.pop(); + // Leaving `( … )`: everything the subshell changed dies with it — + // its cwd, its exports and its shell-local variables. + restoreShellState(subshellCwds.pop()); subshellDepth--; } const analysis = analyzeRun(run); @@ -1599,14 +1710,31 @@ async function evaluateCommandWithCwd( case 'git': { const invocation = readGitInvocation(analysis.tokens); // A path this command relinked defeats a containment check made - // afterwards — but only for an invocation that resolves a path. - // `mv old new && git add -A` targets nothing and stays allowed. + // afterwards. A relinked `.git` redirects discovery for every later + // command; otherwise only a run that resolves one of those very + // paths is affected, so `mv old new && git add -A` stays allowed. + const resolvedRelocations = [ + ...invocation.cwdTargets, + ...invocation.gitDirTargets, + ...invocation.workTreeTargets, + ].map((target) => + trackedCwd === undefined + ? target.text + : path.resolve(trackedCwd, target.text), + ); + if (trackedCwd !== undefined && trackedCwd !== entryCwd) { + resolvedRelocations.push(trackedCwd); + } if ( - relinkedPaths && - (invocation.cwdTargets.length > 0 || - invocation.gitDirTargets.length > 0 || - invocation.workTreeTargets.length > 0 || - trackedCwd !== entryCwd) + relinkedGitDir || + resolvedRelocations.some((target) => + relinkedTargets.some( + (relinked) => + target === relinked || + isWithinRoot(target, relinked) || + isWithinRoot(relinked, target), + ), + ) ) { return { denial: denyDynamicRelocation(), @@ -1698,10 +1826,23 @@ async function evaluateCommandWithCwd( break; case 'other': { if ( - run.length > 0 && - PATH_RELINKING_PROGRAMS.has(executableBaseName(run[0]!)) + run.some((t) => PATH_RELINKING_PROGRAMS.has(executableBaseName(t))) ) { - relinkedPaths = true; + // Wrappers and leading assignments (`env ln …`, `X=1 ln …`) keep + // the relinking program out of run[0], so scan the whole run. + for (const operand of run) { + if (operand.text.startsWith('-')) continue; + if (PATH_RELINKING_PROGRAMS.has(executableBaseName(operand))) { + continue; + } + if (operand.dynamic || trackedCwd === undefined) { + relinkedGitDir = true; + continue; + } + const resolved = path.resolve(trackedCwd, operand.text); + relinkedTargets.push(resolved); + if (path.basename(resolved) === '.git') relinkedGitDir = true; + } } if (analysis.assignmentsOnly) { if (allExport) { @@ -1712,7 +1853,20 @@ async function evaluateCommandWithCwd( } else { for (const token of run) { const key = leadingEnvAssignmentKey(token.text); - if (key !== null) shellLocals.set(key, token); + if (key === null) continue; + const previous = shellLocals.get(key); + if (!isAppendAssignment(token.text) || previous === undefined) { + shellLocals.set(key, token); + continue; + } + // `X+=…` appends: keep the accumulated value so a later `$X` + // expands to what the shell would run. + shellLocals.set(key, { + text: + previous.text + + token.text.slice(token.text.indexOf('=') + 1), + dynamic: previous.dynamic || token.dynamic, + }); } } } @@ -1737,11 +1891,11 @@ async function evaluateCommandWithCwd( // did with parentheses: `(cd )` opens and closes within it, so // the subshell's cwd must not survive into the next segment. while (tokenized!.endDepth < subshellDepth) { - trackedCwd = subshellCwds.pop(); + restoreShellState(subshellCwds.pop()); subshellDepth--; } while (tokenized!.endDepth > subshellDepth) { - subshellCwds.push(trackedCwd); + subshellCwds.push(snapshotShellState()); subshellDepth++; } } From 2bf64cda1d8e73b96009324b00ac6367eb4f5d99 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 19:06:27 +0800 Subject: [PATCH 18/45] fix(serve): carry relink and shell state across nested scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 findings, all reproduced against the real guard. Four of the five are in the machinery I added over the last two commits. - Relink state was local to one `evaluateCommandWithCwd` call, so a symlink created inside `sh -c '…'`, `eval '…'` or a `$(…)` body was invisible to the parent, and a relink made in the parent was invisible to a nested Git run. It is now shared by reference in both directions. - Nothing consulted it for an unrecognized or dynamic program word, so `… && nice git add -A` after a relinked `.git` was allowed, and `X=ln; $X -s /.git .git` recorded nothing at all. A dynamic program word may itself be `ln`, so its operands are recorded too. - `<(…)` opens a paren that shell-quote reports without one, while its `)` still arrives — so `(cd ; <(true); git reset --hard)` popped the subshell early and lost the `cd`. My round-4 triage called this one "not reproduced" because the probe used the top-level shape, which survives on the `Math.max(0, …)` clamp; the nested shape does not. - `eval` ran with an empty shell-local map, so `GIT_DIR=/meta; eval 'export GIT_DIR'` promoted invisibly. Locals now flow into `eval` and into substitution subshells (by copy, since their own assignments die with them); a `sh -c` subprocess still gets none. - Any unreadable word in a shell wrapper's argv can be the `-c` that carries the command, so `bash $A "$P"` is undecidable rather than absent. --- .../serve/daemon-git-worktree-guard.test.ts | 37 +++++++++ .../src/serve/daemon-git-worktree-guard.ts | 81 +++++++++++++++---- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index f6efca80b15..2b1844ea3e8 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1373,6 +1373,43 @@ it -C ${outsideRepo} reset --hard`, ).resolves.toMatchObject({ allowed: false }); }); + // Relink state crosses scopes in both directions: the symlink a nested + // evaluation creates is just as real, and a parent's relink still misleads + // a nested run. + it.each([ + () => + `sh -c 'rm -rf src && ln -s ${outsideRepo} src' && git -C src reset --hard`, + () => `eval 'ln -s ${outsideRepo} src' && git -C src reset --hard`, + () => `echo $(ln -s ${outsideRepo} src) && git -C src reset --hard`, + () => `ln -s ${outsideRepo} src && sh -c 'git -C src reset --hard'`, + () => `X=ln; $X -s ${path.join(outsideRepo, '.git')} .git && git add -A`, + () => `ln -s ${path.join(outsideRepo, '.git')} .git && nice git add -A`, + ])('carries relink state across scopes %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it.each([ + // `<(…)` opens a paren the tokenizer must count, or its `)` pops the + // enclosing subshell early and the preceding `cd` is lost. + () => `(cd ${outsideRepo}; <(true); git reset --hard)`, + // `eval` runs in this shell, so it sees the shell-local assignment. + () => + `GIT_DIR=${outsideRepo}/meta; eval 'export GIT_DIR'; git reset --hard`, + // Any unreadable word in a shell's argv can be the `-c`. + () => `A='-c'; bash $A "$P"`, + () => `bash $A "$P"`, + ])('fails closed on the round-5 scope gaps %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 9bca76b1982..659dfb2582e 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -379,8 +379,13 @@ function tokenizeSegment( runs.at(-1)!.tokens.push({ text: pattern, dynamic: true }); continue; } - if (op === '(' || op === ')') { - depth = op === '(' ? depth + 1 : Math.max(0, depth - 1); + if (op === '(' || op === '<(' || op === '>(') { + depth++; + runs.push({ tokens: [], depth }); + continue; + } + if (op === ')') { + depth = Math.max(0, depth - 1); runs.push({ tokens: [], depth }); continue; } @@ -714,10 +719,10 @@ function consumeShellWrapper( return { kind: 'static', payload: payloadToken.text }; } if (token.dynamic) { - // `bash -c$CMD`: the payload is fused into this token and unresolved. - return shellBundleRequestsCommand(token.text) - ? { kind: 'dynamic' } - : { kind: 'none' }; + // `bash -c$CMD`, `bash $A "$P"`: any unreadable word in a shell's argv + // can be the `-c` that carries the command, so the wrapper as a whole + // is undecidable rather than absent. + return { kind: 'dynamic' }; } if (shellBundleRequestsCommand(token.text)) { const remainder = token.text.slice(token.text.indexOf('c') + 1); @@ -1487,8 +1492,14 @@ async function evaluateUnrecognizedRun( basisCwd: string | undefined, entryCwd: string | undefined, context: GuardEvaluationContext, + relink?: RelinkState, ): Promise { if (!run.some((token) => GIT_WORD_PATTERN.test(token.text))) return undefined; + // A relinked `.git` redirects discovery for whatever git this run executes, + // exactly as it would for a recognized one. + if (relink?.gitDir) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } // `grep -C 5 git CHANGELOG.md` carries a `-C` that has nothing to do with // git, so the program's own flag vocabulary decides whether it is a marker. const ownsCFlag = @@ -1520,6 +1531,24 @@ async function evaluateUnrecognizedRun( return denyOutsideDiscoveredRepository(canonicalBasis, context); } +/** + * State that outlives the scope it was created in. A relink performed inside + * `sh -c '…'` still changes the real filesystem, and a relink performed in + * the parent still misleads a nested run — so this is shared by reference in + * both directions rather than merged after the fact. + */ +interface RelinkState { + readonly targets: string[]; + gitDir: boolean; +} + +interface EvaluationScope { + readonly relink: RelinkState; + // Shell variables the nested command can see: `eval` and subshells inherit + // them, a `sh -c` subprocess does not. + readonly locals?: Map; +} + interface CommandEvaluation { readonly denial?: GuardDenial; readonly cwdAfter: string | undefined; @@ -1536,6 +1565,7 @@ async function evaluateCommandWithCwd( entryCwd: string | undefined, context: GuardEvaluationContext, depth: number, + scope: EvaluationScope = { relink: { targets: [], gitDir: false } }, ): Promise { let trackedCwd = startCwd; // Assignments this command exported into the environment of everything that @@ -1544,13 +1574,11 @@ async function evaluateCommandWithCwd( let allExport = false; // GIT_* assignments made without `export`. They stay shell-local until a // name-only `export GIT_DIR` promotes them into the environment. - const shellLocals = new Map(); + const shellLocals = scope.locals ?? new Map(); // Paths a run in this command may have re-pointed. Any containment the // guard proves for one of them afterwards is proved against the old target. - const relinkedTargets: string[] = []; - // Set when a relinked path is (or may be) a `.git`, which redirects the - // repository discovery of every later command, relocated or not. - let relinkedGitDir = false; + // Shared with every nested evaluation, in both directions. + const relinkedTargets = scope.relink.targets; // Exported relocations reach every later command, including the ones nested // inside a wrapper payload or a substitution body. const activeContext = (): GuardEvaluationContext => @@ -1615,6 +1643,9 @@ async function evaluateCommandWithCwd( entryCwd, activeContext(), depth + 1, + // A substitution runs in a subshell: it inherits the variables but + // its own assignments die with it, so it gets a copy. + { relink: scope.relink, locals: new Map(shellLocals) }, ); if (nested.denial) { return { denial: nested.denial, cwdAfter: trackedCwd }; @@ -1688,6 +1719,12 @@ async function evaluateCommandWithCwd( entryCwd, ambient, depth + 1, + { + relink: scope.relink, + // `eval` runs in this very shell, so it sees these variables; + // a `sh -c` subprocess inherits only exported ones. + ...(analysis.propagatesCwd ? { locals: shellLocals } : {}), + }, ); if (nested.denial) { return { denial: nested.denial, cwdAfter: trackedCwd }; @@ -1726,7 +1763,7 @@ async function evaluateCommandWithCwd( resolvedRelocations.push(trackedCwd); } if ( - relinkedGitDir || + scope.relink.gitDir || resolvedRelocations.some((target) => relinkedTargets.some( (relinked) => @@ -1747,6 +1784,7 @@ async function evaluateCommandWithCwd( trackedCwd, entryCwd, activeContext(), + scope.relink, ); if (denial) return { denial, cwdAfter: trackedCwd }; break; @@ -1756,6 +1794,17 @@ async function evaluateCommandWithCwd( const expanded = analysis.rest.map((token) => expandShellLocals(token, shellLocals), ); + // The program word is unreadable, so it may be `ln`: record its + // operands as possibly re-pointed (`X=ln; $X -s /.git .git`). + for (const operand of expanded) { + if (operand.text.startsWith('-') || operand.dynamic) continue; + if (trackedCwd === undefined) { + scope.relink.gitDir = true; + continue; + } + const resolved = path.resolve(trackedCwd, operand.text); + if (path.basename(resolved) === '.git') scope.relink.gitDir = true; + } if ( analysis.state.unresolved || analysis.state.relocations.length > 0 || @@ -1787,6 +1836,7 @@ async function evaluateCommandWithCwd( trackedCwd, entryCwd, inherited, + scope.relink, ); if (denial) return { denial, cwdAfter: trackedCwd }; break; @@ -1817,6 +1867,7 @@ async function evaluateCommandWithCwd( trackedCwd, entryCwd, activeContext(), + scope.relink, ); if (denial) return { denial, cwdAfter: trackedCwd }; break; @@ -1836,12 +1887,13 @@ async function evaluateCommandWithCwd( continue; } if (operand.dynamic || trackedCwd === undefined) { - relinkedGitDir = true; + scope.relink.gitDir = true; continue; } const resolved = path.resolve(trackedCwd, operand.text); relinkedTargets.push(resolved); - if (path.basename(resolved) === '.git') relinkedGitDir = true; + if (path.basename(resolved) === '.git') + scope.relink.gitDir = true; } } if (analysis.assignmentsOnly) { @@ -1876,6 +1928,7 @@ async function evaluateCommandWithCwd( trackedCwd, entryCwd, activeContext(), + scope.relink, ); if (denial) return { denial, cwdAfter: trackedCwd }; break; From 0a58fe70cdbead18d20b07b37e97d65f5c94e902 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 9 Aug 2026 19:17:32 +0800 Subject: [PATCH 19/45] fix(daemon): evaluate the guard against the directory the tool runs in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sub-agent pinned to a worktree — `working_dir`, or `isolation`, which sets `ov.targetDir` on the child Config — executes at `config.getTargetDir()` while still reporting the parent session id. The guard context carried only that session id, so the daemon evaluated every such call against the parent session's `effectiveCwd`: a plain `git commit` from an isolated sub-agent was judged in-boundary and allowed while running somewhere else entirely, and a relative `-C` resolved against a directory the command would never be in. The invocation now carries the directory it will run in, from `config.getTargetDir()` through the child guard to the daemon. It is explicitly untrusted, so the daemon accepts it only where it can verify it from state it owns: inside the session's effective working directory, or inside the worktree tree that session owns — worktrees live under `GitWorktreeService.getWorktreesDir()`, and the session id is already validated by `ownsSession`. Anywhere else the scope cannot be established and the call fails closed. When an owned worktree is accepted it becomes the boundary, so an isolated sub-agent is contained to its own worktree instead of to its parent's checkout — reaching back into the parent is now denied, which is the escape this was reported for. --- docs/design/daemon-git-worktree-guard.md | 12 +++++ docs/users/qwen-serve.md | 4 ++ packages/acp-bridge/src/bridgeClient.ts | 6 +++ packages/acp-bridge/src/bridgeOptions.ts | 6 +++ packages/cli/src/acp-integration/acpAgent.ts | 5 ++ .../serve/daemon-git-worktree-guard.test.ts | 52 ++++++++++++++++++- .../src/serve/daemon-git-worktree-guard.ts | 47 ++++++++++++++--- .../core/src/core/coreToolScheduler.test.ts | 2 + packages/core/src/core/coreToolScheduler.ts | 1 + .../core/src/core/tool-invocation-guard.ts | 8 +++ .../core/src/followup/speculation.test.ts | 4 ++ packages/core/src/followup/speculation.ts | 1 + 12 files changed, 141 insertions(+), 7 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index c1c94bc6b77..9d34552a0db 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -84,6 +84,18 @@ analyzed as nested commands against the current tracked directory; their own stepped over, though a substitution nested inside it is still analyzed. An unterminated substitution is denied as unparseable. +A sub-agent pinned to a worktree (`working_dir`, or `isolation`, which +rebinds the child Config's cwd surfaces) executes there while still reporting +the parent session id, so the session's own directory is not where the +command runs. The child reports that directory alongside the request; it is +untrusted, so the daemon accepts it only where it can verify it from state it +owns — inside the session's effective working directory, or inside the +worktree tree that session owns (`GitWorktreeService.getWorktreesDir()`). Anywhere else the scope cannot be established and the call fails +closed. When an owned worktree is accepted it becomes the boundary, so an +isolated sub-agent is contained to its own worktree instead of to its +parent's checkout. + Relative targets resolve from the command's effective starting directory: `arguments.directory` when present, otherwise the session's current effective working directory. A model-supplied `directory` is itself canonicalized and diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 473eb548067..b46ab875a11 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -457,6 +457,10 @@ directory-shifting wrapper flags (`env -C`, `sudo -D`), and `cd`, `pushd`, or unwrapped so the same policy applies to the inner Git invocation, and `$(…)` or backtick substitution bodies are analyzed as commands of their own. +A sub-agent pinned to its own worktree is contained to that worktree rather +than to the session's directory; a shell call whose execution directory the +daemon cannot place is denied. + Relative targets resolve from the command's effective starting directory (`arguments.directory` when present, otherwise the session's current effective working directory) after canonical path resolution, including `.git` gitfile diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 9af8a113114..e46af737216 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -1250,6 +1250,7 @@ export class BridgeClient implements Client { 'External tool guard prompt is not the active prompt', ); } + const invocationCwd = params['invocationCwd']; const decision: unknown = await this.externalToolGuard({ sessionId: entry.sessionId, ...(promptScoped ? { promptId } : {}), @@ -1257,6 +1258,11 @@ export class BridgeClient implements Client { toolName, arguments: args, effectiveCwd: entry.effectiveCwd, + // Forwarded verbatim and explicitly untrusted: the host policy decides + // whether it can establish this scope from state it owns. + ...(typeof invocationCwd === 'string' && invocationCwd.length > 0 + ? { invocationCwd } + : {}), }); const currentEntry = this.resolveEntry(sessionId); if ( diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index d8bc7159dc3..766b441f3d1 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -86,6 +86,12 @@ export interface ExternalToolGuardPrepareRequest { readonly arguments: Readonly>; /** Daemon-owned current session working directory. */ readonly effectiveCwd?: string; + /** + * Directory the child will actually run the tool in, when it differs from + * the session's own. Untrusted: the host validates it against state it + * owns before using it as a containment basis. + */ + readonly invocationCwd?: string; } export type ExternalToolGuardPrepareResult = diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index cc616e7f5da..6fdd6528463 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2856,6 +2856,11 @@ export function createManagedExternalToolGuard( toolCallId: context.callId, toolName: context.toolName, arguments: context.args, + // A sub-agent pinned to a worktree executes here, not in the + // session's own directory; the host validates this before use. + ...(typeof context.cwd === 'string' && context.cwd.length > 0 + ? { invocationCwd: context.cwd } + : {}), }, ), aborted, diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 2b1844ea3e8..8e8fc1b96a2 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -9,7 +9,7 @@ import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import { ToolNames } from '@qwen-code/qwen-code-core'; +import { GitWorktreeService, ToolNames } from '@qwen-code/qwen-code-core'; import type { ExternalToolGuardPrepareRequest } from '@qwen-code/acp-bridge/bridgeOptions'; import { SHELL_EXECUTING_TOOL_NAMES } from '@qwen-code/acp-bridge/externalToolGuard'; import { createDaemonToolGuard } from './daemon-git-worktree-guard.js'; @@ -1410,6 +1410,56 @@ it -C ${outsideRepo} reset --hard`, }); }); + // A sub-agent pinned to a worktree executes there while reporting the + // parent session id, so the session's own directory is not the boundary. + describe('reported execution directory', () => { + const call = ( + command: string, + invocationCwd?: string, + ): ExternalToolGuardPrepareRequest => + ({ + ...request(command), + ...(invocationCwd === undefined ? {} : { invocationCwd }), + }) as ExternalToolGuardPrepareRequest; + + it('accepts a directory inside the session', async () => { + const guard = createDaemonToolGuard(); + + await expect( + guard(call('git commit -m x', insideNested)), + ).resolves.toEqual({ allowed: true }); + }); + + it('fails closed on a directory the daemon cannot place', async () => { + const guard = createDaemonToolGuard(); + + // The session id owns no worktree here, so this scope is unverifiable. + await expect( + guard(call('git commit -m x', outsideRepo)), + ).resolves.toMatchObject({ + allowed: false, + reason: expect.stringContaining('execution directory'), + }); + }); + + it('contains a sub-agent to the worktree it reports', async () => { + const owned = GitWorktreeService.getWorktreesDir('session-1'); + const agentWorktree = path.join(owned, 'agent-a'); + await mkdir(path.join(agentWorktree, 'src'), { recursive: true }); + + const guard = createDaemonToolGuard(); + // Its own worktree is the boundary: work inside it is allowed... + await expect( + guard(call('cd src && git commit -m x', agentWorktree)), + ).resolves.toEqual({ allowed: true }); + // ...while reaching back into the parent checkout is not. + await expect( + guard(call(`git -C ${effectiveCwd} reset --hard`, agentWorktree)), + ).resolves.toMatchObject({ allowed: false }); + await rm(owned, { recursive: true, force: true }); + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 659dfb2582e..cde2eab3ba1 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -8,6 +8,7 @@ import { realpath, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { parse } from 'shell-quote'; import { + GitWorktreeService, isWithinRoot, realpathNearestExistingAsync, splitCommands, @@ -199,6 +200,9 @@ interface TrustedDaemonToolGuardRequest readonly effectiveCwd: string; } +const UNVERIFIABLE_SCOPE_DENIAL_PREFIX = + 'Daemon shell guard could not establish the execution directory of this call: '; + interface GuardToken { readonly text: string; readonly dynamic: boolean; @@ -281,6 +285,15 @@ function executableBaseName(token: GuardToken): string { return base.toLowerCase().replace(/\.exe$/i, ''); } +// `(` opens a subshell; `<(`/`>(` open a process substitution. All three are +// closed by a `)` that arrives on its own, so all three must raise the depth +// or that `)` pops a scope that was never opened. +const SUBSHELL_OPENING_OPERATORS: ReadonlySet = new Set([ + '(', + '<(', + '>(', +]); + const REDIRECT_OPERATORS = new Set([ '<', '>', @@ -379,7 +392,7 @@ function tokenizeSegment( runs.at(-1)!.tokens.push({ text: pattern, dynamic: true }); continue; } - if (op === '(' || op === '<(' || op === '>(') { + if (SUBSHELL_OPENING_OPERATORS.has(op)) { depth++; runs.push({ tokens: [], depth }); continue; @@ -1784,7 +1797,6 @@ async function evaluateCommandWithCwd( trackedCwd, entryCwd, activeContext(), - scope.relink, ); if (denial) return { denial, cwdAfter: trackedCwd }; break; @@ -1967,9 +1979,32 @@ async function evaluateBuiltInGuard( const command = request.arguments['command']; if (typeof command !== 'string') return { allowed: true }; - const canonicalEffectiveCwd = await realpathNearestExistingAsync( - request.effectiveCwd, - ); + const sessionCwd = await realpathNearestExistingAsync(request.effectiveCwd); + + // A sub-agent pinned to a worktree (`working_dir`, or `isolation`, which + // rebinds the child Config's cwd surfaces) executes there while reporting + // the parent session id, so the session's own directory is not where the + // command runs. The child reports that directory; it is untrusted, so it is + // only accepted where the daemon can verify it from state it owns: inside + // the session's effective working directory, or inside the worktree tree + // this very session owns (`GitWorktreeService.getWorktreesDir(sessionId)`). + // Anywhere else the scope cannot be established and the call fails closed — + // and the accepted directory becomes the boundary, so an isolated sub-agent + // is contained to its own worktree rather than to its parent's checkout. + let canonicalEffectiveCwd = sessionCwd; + const reportedCwd = request.invocationCwd; + if (typeof reportedCwd === 'string' && reportedCwd.length > 0) { + const canonicalReported = await realpathNearestExistingAsync(reportedCwd); + if (!isWithinRoot(canonicalReported, sessionCwd)) { + const ownedWorktrees = await realpathNearestExistingAsync( + GitWorktreeService.getWorktreesDir(request.sessionId), + ); + if (!isWithinRoot(canonicalReported, ownedWorktrees)) { + return denyTarget(UNVERIFIABLE_SCOPE_DENIAL_PREFIX, canonicalReported); + } + canonicalEffectiveCwd = canonicalReported; + } + } // A model-supplied `directory` becomes the containment basis, so it must // itself stay inside the effective working directory before it is trusted. @@ -1977,7 +2012,7 @@ async function evaluateBuiltInGuard( const startDirectoryValue = request.arguments['directory']; if (typeof startDirectoryValue === 'string') { startDirectory = await realpathNearestExistingAsync( - path.resolve(request.effectiveCwd, startDirectoryValue), + path.resolve(canonicalEffectiveCwd, startDirectoryValue), ); if (!isWithinRoot(startDirectory, canonicalEffectiveCwd)) { return denyTarget(OUTSIDE_TARGET_DENIAL_PREFIX, startDirectory); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 7855b8254c7..b26417ea4ba 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -9913,6 +9913,7 @@ describe('CoreToolScheduler Plan shell routing', () => { args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), sessionId: 'plan-shell-session', + cwd: '/workspace', }); expect(execute).not.toHaveBeenCalled(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; @@ -9950,6 +9951,7 @@ describe('CoreToolScheduler Plan shell routing', () => { args: { command: 'git status', directory: '/workspace' }, signal: expect.any(AbortSignal), sessionId: 'plan-shell-session', + cwd: '/workspace', }); expect(execute).toHaveBeenCalledOnce(); const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 0a1a8f147aa..377e231abed 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -4480,6 +4480,7 @@ export class CoreToolScheduler { args: invocation.params as Record, signal, sessionId: this.config.getSessionId(), + cwd: this.config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/core/src/core/tool-invocation-guard.ts b/packages/core/src/core/tool-invocation-guard.ts index 98e969b8173..4c64659fd9c 100644 --- a/packages/core/src/core/tool-invocation-guard.ts +++ b/packages/core/src/core/tool-invocation-guard.ts @@ -24,6 +24,14 @@ export interface ToolInvocationGuardContext { * session scope may fall back to it instead of failing closed. */ sessionId?: string; + /** + * The directory the invocation will actually execute in — the scheduler's + * `config.getTargetDir()`. A sub-agent pinned to a worktree (`working_dir`, + * or `isolation`, which rebinds the child Config's cwd surfaces) runs there + * while still reporting the parent's {@link sessionId}, so a host that + * reasons about paths cannot assume the session's own directory. + */ + cwd?: string; } export type ToolInvocationGuardDecision = diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index 2763d496734..e2da6b366a3 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -62,6 +62,7 @@ describe('startSpeculation', () => { getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getSessionId: vi.fn().mockReturnValue('spec-session'), + getTargetDir: vi.fn().mockReturnValue('/spec/cwd'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -103,6 +104,7 @@ describe('startSpeculation', () => { args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), sessionId: 'spec-session', + cwd: '/spec/cwd', }); expect(execute).not.toHaveBeenCalled(); @@ -128,6 +130,7 @@ describe('startSpeculation', () => { getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getSessionId: vi.fn().mockReturnValue('spec-session'), + getTargetDir: vi.fn().mockReturnValue('/spec/cwd'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), } as unknown as Config; @@ -169,6 +172,7 @@ describe('startSpeculation', () => { args: { path: '/normalized/a.ts' }, signal: expect.any(AbortSignal), sessionId: 'spec-session', + cwd: '/spec/cwd', }); expect(execute).toHaveBeenCalledOnce(); diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 90c6c0fdc17..5106ef68c79 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -350,6 +350,7 @@ async function runSpeculativeLoop( args: invocation.params as Record, signal: state.abortController!.signal, sessionId: config.getSessionId(), + cwd: config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); From 3a247f76708223cfa58bf2e895d7b0dbdc6f8a91 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 10 Aug 2026 12:27:55 +0800 Subject: [PATCH 20/45] fix(daemon): reach the real guard path and close the round-6 escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline finding is that the previous round's fix never reached the code it was written for. `Session.runTool` — the path daemon ACP sessions actually execute tools through — built the guard context without `sessionId` and without `cwd`, so both the session fallback this PR added and the execution directory added last round were unreachable there. Both are now supplied, exactly as `CoreToolScheduler` does. Escapes, each reproduced against the real guard first: - `GIT_SSH_COMMAND`, `GIT_EDITOR`, `GIT_SEQUENCE_EDITOR`, `GIT_ASKPASS`, `GIT_PAGER`, `GIT_EXTERNAL_DIFF`, `GIT_SSH` are programs git executes, and `GIT_CONFIG_PARAMETERS`/`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_` are its environment config channel — none were modelled. - `diff.external`, `core.gitProxy`, `interactive.diffFilter`, `credential..helper`, `remote..uploadpack`/`receivepack`/`proxy`, `tar..command`, `browser..cmd`, `web.browser`, `help.browser`, `gc.recentObjectsHook` and `ssh.variant` join the command-executing config keys. - An unrecognized wrapper laundered that config: `nice git -c alias.pwn='!…'` never reached the git analysis. It is checked there too now. - `find -execdir git reset --hard` relocates through the program's own flag, leaving no marker. - An archive decides where it writes, so `tar`/`unzip`/`cpio`/`rsync` make their extraction directory suspect rather than their operands. - `alias g='git reset --hard'; cd ; g` and the function-definition form both defer a body to wherever the bare word is later used. Over-denials, all introduced by earlier rounds of this PR: - `SHELLOPTS=errexit git status` (SHELLOPTS is bash's own options state — the rationale I gave for listing it was simply wrong, and it never reproduced as an escape), `env --ignore-environment`/`--null`/`--debug`, `curl -C - …`, `env -iS 'cmd'`, `d=; cd $d; git status`, and `set +a` turning allexport back off. Also: the sub-agent worktree test created and recursively deleted a directory under the user's global Qwen dir, keyed on a session id a real session could own. It now uses a process-unique id and cleans up in `finally`. --- .../src/acp-integration/session/Session.ts | 7 + .../serve/daemon-git-worktree-guard.test.ts | 87 ++++++- .../src/serve/daemon-git-worktree-guard.ts | 216 +++++++++++++++++- 3 files changed, 292 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bc2ab1d8bd9..a799b365e22 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -9307,6 +9307,13 @@ export class Session implements SessionContext { toolName: policyToolName, args: invocation.params as Record, signal: activeToolAbortSignal, + // Same identity and execution scope `CoreToolScheduler` + // supplies. This is the path daemon ACP sessions actually + // take, so without them a host policy that falls back to the + // session — or reasons about where the tool runs — sees + // neither on every call made here. + sessionId: this.config.getSessionId(), + cwd: this.config.getTargetDir(), ...(invocationContext ? { invocationContext } : {}), }, ); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 8e8fc1b96a2..1eccb5c0da7 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1443,23 +1443,90 @@ it -C ${outsideRepo} reset --hard`, }); it('contains a sub-agent to the worktree it reports', async () => { - const owned = GitWorktreeService.getWorktreesDir('session-1'); + // A session id unique to this test: `getWorktreesDir` resolves under the + // user's global Qwen dir, so a shared id would have this test create and + // delete real directories belonging to someone's session. + const isolatedSessionId = `daemon-guard-${process.pid}-worktree`; + const owned = GitWorktreeService.getWorktreesDir(isolatedSessionId); const agentWorktree = path.join(owned, 'agent-a'); await mkdir(path.join(agentWorktree, 'src'), { recursive: true }); const guard = createDaemonToolGuard(); - // Its own worktree is the boundary: work inside it is allowed... - await expect( - guard(call('cd src && git commit -m x', agentWorktree)), - ).resolves.toEqual({ allowed: true }); - // ...while reaching back into the parent checkout is not. - await expect( - guard(call(`git -C ${effectiveCwd} reset --hard`, agentWorktree)), - ).resolves.toMatchObject({ allowed: false }); - await rm(owned, { recursive: true, force: true }); + const inWorktree = (command: string): ExternalToolGuardPrepareRequest => + ({ + ...call(command, agentWorktree), + sessionId: isolatedSessionId, + }) as ExternalToolGuardPrepareRequest; + try { + // Its own worktree is the boundary: work inside it is allowed... + await expect( + guard(inWorktree('cd src && git commit -m x')), + ).resolves.toEqual({ allowed: true }); + // ...while reaching back into the parent checkout is not. + await expect( + guard(inWorktree(`git -C ${effectiveCwd} reset --hard`)), + ).resolves.toMatchObject({ allowed: false }); + } finally { + await rm(GitWorktreeService.getSessionDir(isolatedSessionId), { + recursive: true, + force: true, + }); + } }); }); + it.each([ + // Env vars git executes as programs, and its config-injection channels. + () => `GIT_SSH_COMMAND='touch /tmp/x' git fetch`, + () => `GIT_EDITOR='touch /tmp/x' git commit`, + () => `GIT_ASKPASS='touch /tmp/x' git fetch`, + () => `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager git status`, + // Config keys git runs through a shell. + () => `git -c diff.external='touch /tmp/x' -C ${outsideRepo} rev-parse`, + () => `git -c core.gitProxy='touch /tmp/x' -C ${outsideRepo} rev-parse`, + // An unrecognized wrapper does not launder that config. + () => `nice git -c alias.pwn='!cd ${outsideRepo} && git reset --hard' pwn`, + // `-execdir` runs git with the cwd of each directory it visits. + () => `find ${outsideRepo} -execdir git reset --hard ;`, + // An archive decides where it writes, so the extraction directory is + // what became untrustworthy. + () => `tar -xf evil.tar && git -C nested reset --hard`, + // A body defined earlier runs where the later bare word appears. + () => `alias g='git reset --hard'; cd ${outsideRepo}; g`, + () => `f() { git reset --hard; }; cd ${outsideRepo}; f`, + ])('closes the round-6 escapes %#', async (build) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + // Each of these was denied by a rule that was too broad. + it('keeps ordinary commands out of the round-6 rules', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // `SHELLOPTS` is bash's own options state, not a git redirection. + 'SHELLOPTS=errexit git status', + 'env --ignore-environment git status', + 'env --null git status', + // `curl -C -` resumes a download; it is not `git -C`. + 'curl -C - -o pkg.tgz https://git.example.com/pkg.tgz', + "env -iS 'git status'", + // A `cd` target the guard already knows the value of. + `d=${insideNested}; cd $d; git status`, + // `set +a` turns allexport back off. + `set -a; set +a; GIT_WORK_TREE=${outsideRepo}; echo done`, + // Definitions used inside the boundary stay allowed. + 'f() { git status; }; cd nested; f', + "alias g='git status'; cd nested; g", + 'tar -xf a.tar && git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index cde2eab3ba1..9785fb3012b 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -79,6 +79,17 @@ const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ /^pager\./, /^sequence\.editor$/, /^uploadpack\.packobjectshook$/, + /^browser\..+\.cmd$/, + /^core\.gitproxy$/, + /^credential\..+\.helper$/, + /^diff\.external$/, + /^gc\.recentobjectshook$/, + /^help\.browser$/, + /^interactive\.difffilter$/, + /^remote\..+\.(proxy|receivepack|uploadpack)$/, + /^ssh\.variant$/, + /^tar\..+\.command$/, + /^web\.browser$/, ]; // Environment assignments that redirect git's repository selection (mirrors @@ -92,13 +103,25 @@ const GIT_WORK_TREE_ENV_KEYS = new Set(['GIT_INDEX_FILE', 'GIT_WORK_TREE']); // file — enough to point `core.hooksPath` outside. const GIT_UNRESOLVABLE_ENV_KEYS = new Set([ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_ASKPASS', 'GIT_CONFIG', + 'GIT_CONFIG_COUNT', 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_SYSTEM', + 'GIT_EDITOR', + 'GIT_EXTERNAL_DIFF', 'GIT_OBJECT_DIRECTORY', - 'SHELLOPTS', + 'GIT_PAGER', + 'GIT_SEQUENCE_EDITOR', + 'GIT_SSH', + 'GIT_SSH_COMMAND', ]); +// `GIT_CONFIG_KEY_`/`GIT_CONFIG_VALUE_` are the numbered half of git's +// environment config channel — equivalent to `-c =`. +const GIT_NUMBERED_CONFIG_ENV_PATTERN = /^GIT_CONFIG_(KEY|VALUE)_\d+$/; + const SHELL_WRAPPER_PROGRAMS = new Set(['bash', 'dash', 'ksh', 'sh', 'zsh']); const SHELL_WRAPPER_VALUE_FLAGS = new Set(['-o', '-O']); @@ -116,7 +139,15 @@ function shellBundleRequestsCommand(flag: string): boolean { const ENV_CHDIR_FLAGS = new Set(['-C', '--chdir']); const ENV_VALUE_FLAGS = new Set(['-S', '--split-string', '-u', '--unset']); -const ENV_KNOWN_FLAG_ONLY = new Set(['-', '-0', '-i', '-v']); +const ENV_KNOWN_FLAG_ONLY = new Set([ + '-', + '-0', + '-i', + '-v', + '--null', + '--ignore-environment', + '--debug', +]); // Union of core shell-utils/shell.ts value-taking sudo options. const SUDO_VALUE_FLAGS = new Set([ @@ -152,10 +183,16 @@ const TIMEOUT_VALUE_FLAGS = new Set(['-k', '-s', '--kill-after', '--signal']); // is checked while `bait` is still the original directory. const PATH_RELINKING_PROGRAMS = new Set(['cp', 'ln', 'mv']); +// Archive extractors do not name the paths they write: the archive decides. +// Everything under their extraction directory is therefore suspect, which is +// the directory itself rather than any operand. +const PATH_EXTRACTING_PROGRAMS = new Set(['cpio', 'rsync', 'tar', 'unzip']); + // Programs whose own `-C` means something else entirely (`grep -C 5`, // `tar -C dir`), so it must not read as a git relocation marker. const PROGRAMS_WITH_OWN_C_FLAG = new Set([ 'cmake', + 'curl', 'cpio', 'diff', 'grep', @@ -475,9 +512,9 @@ const GIT_WORD_PATTERN = /\bgit\b/i; // A `cd`/`pushd` inside such a payload relocates the git that follows it just // as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). const TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN = - /(^|\s)(--git-dir=?|--work-tree=?)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; const TEXT_RELOCATION_MARKER_PATTERN = - /(^|\s)(-C|--git-dir=?|--work-tree=?)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; // Assignments that decide WHICH git binary the run executes. The guard // classifies the program word `git` and then reasons about paths; if the @@ -487,7 +524,11 @@ const GIT_PROGRAM_ENV_KEYS = new Set(['GIT_EXEC_PATH', 'PATH']); function recordEnvAssignment(token: GuardToken, state: PrefixState): void { const key = leadingEnvAssignmentKey(token.text); if (key === null) return; - if (GIT_PROGRAM_ENV_KEYS.has(key) || GIT_UNRESOLVABLE_ENV_KEYS.has(key)) { + if ( + GIT_PROGRAM_ENV_KEYS.has(key) || + GIT_UNRESOLVABLE_ENV_KEYS.has(key) || + GIT_NUMBERED_CONFIG_ENV_PATTERN.test(key) + ) { state.unresolved = true; return; } @@ -601,6 +642,16 @@ function consumeEnvWrapper( payload: rest ? `${fused} ${rest}` : fused, }; } + // `env -iS 'cmd'`: the bundle ends at `S`, so the payload is the next + // argv entry — the same rule `sh -lc 'cmd'` follows. + const payloadToken = run[index + 1]; + if (payloadToken === undefined) return { next: run.length }; + if (payloadToken.dynamic) return { next: run.length, undecidable: true }; + const rest = joinTokenTexts(run.slice(index + 2)); + return { + next: run.length, + payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, + }; } if (ENV_VALUE_FLAGS.has(token.text)) { index += 2; @@ -786,6 +837,7 @@ type RunAnalysis = | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } | { kind: 'export'; state: PrefixState; operands: GuardToken[] } | { kind: 'all-export' } + | { kind: 'all-export-off' } | { kind: 'undecidable' } | { kind: 'other'; state: PrefixState; assignmentsOnly: boolean }; @@ -873,6 +925,60 @@ function requestsAllExport(run: GuardToken[], start: number): boolean { return false; } +// `set +a` / `set +o allexport` turn it back off. +function disablesAllExport(run: GuardToken[], start: number): boolean { + for (let index = start; index < run.length; index++) { + const text = run[index]!.text; + if (text === '+o' && run[index + 1]?.text === 'allexport') return true; + if (/^\+[a-zA-Z]*a/.test(text)) return true; + } + return false; +} + +/** + * `alias name='body'` and `name() { body; }` both defer a command: the body + * runs where the *later* bare word appears, not where it was written. + */ +/** `f()` / `f ()` — the header of a function definition, if this is one. */ +function readFunctionName(run: GuardToken[]): string | undefined { + if (run.length === 0) return undefined; + const first = run[0]!.text; + if (first.endsWith('()') && first.length > 2) return first.slice(0, -2); + if (run[1]?.text === '()') return first; + return undefined; +} + +function readDefinition( + run: GuardToken[], +): { name: string; body: string } | undefined { + if (run.length === 0) return undefined; + const program = executableBaseName(run[0]!); + if (program === 'alias') { + for (const token of run.slice(1)) { + const separator = token.text.indexOf('='); + if (separator <= 0) continue; + return { + name: token.text.slice(0, separator), + body: token.text.slice(separator + 1), + }; + } + return undefined; + } + // shell-quote yields `f()` (or `f` `()`), then the braced body tokens. + const first = run[0]!.text; + const name = first.endsWith('()') + ? first.slice(0, -2) + : run[1]?.text === '()' + ? first + : undefined; + if (!name) return undefined; + const bodyTokens = run + .slice(first.endsWith('()') ? 1 : 2) + .filter((token) => token.text !== '{' && token.text !== '}'); + if (bodyTokens.length === 0) return undefined; + return { name, body: joinTokenTexts(bodyTokens) }; +} + function analyzeRun(run: GuardToken[]): RunAnalysis { const state: PrefixState = { relocations: [], unresolved: false }; let index = 0; @@ -904,8 +1010,9 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { for (const operand of operands) recordEnvAssignment(operand, state); return { kind: 'export', state, operands }; } - if (program === 'set' && requestsAllExport(run, index + 1)) { - return { kind: 'all-export' }; + if (program === 'set') { + if (requestsAllExport(run, index + 1)) return { kind: 'all-export' }; + if (disablesAllExport(run, index + 1)) return { kind: 'all-export-off' }; } if (program === 'env') { const scan = consumeEnvWrapper(run, index, state); @@ -1513,6 +1620,14 @@ async function evaluateUnrecognizedRun( if (relink?.gitDir) { return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; } + // `nice git -c alias.pwn='!…' pwn`: the wrapper hides the invocation from + // the git analysis, but the config it carries executes just the same. + const gitIndex = run.findIndex( + (token) => executableBaseName(token) === 'git', + ); + if (gitIndex >= 0 && readGitInvocation(run.slice(gitIndex)).dangerousConfig) { + return { allowed: false, reason: UNRECOGNIZED_PROGRAM_DENIAL }; + } // `grep -C 5 git CHANGELOG.md` carries a `-C` that has nothing to do with // git, so the program's own flag vocabulary decides whether it is a marker. const ownsCFlag = @@ -1588,6 +1703,14 @@ async function evaluateCommandWithCwd( // GIT_* assignments made without `export`. They stay shell-local until a // name-only `export GIT_DIR` promotes them into the environment. const shellLocals = scope.locals ?? new Map(); + // `alias g='git …'` and `f() { git …; }` both make a later bare word run a + // body defined earlier; without them that word is an opaque `other` run. + const definedBodies = new Map(); + // Function bodies that `splitCommands` cut across segments cannot be + // replayed verbatim, so the name is recorded as Git-shaped instead and the + // later bare word answers to the unrecognized-program containment rule. + const gitShapedNames = new Set(); + let insideDefinition = false; // Paths a run in this command may have re-pointed. Any containment the // guard proves for one of them afterwards is proved against the old target. // Shared with every nested evaluation, in both directions. @@ -1644,6 +1767,24 @@ async function evaluateCommandWithCwd( cwdAfter: trackedCwd, }; } + // `name() { … }` — shell-quote reports the parentheses as operators, so + // the header is recognised on the raw segment. The body runs wherever the + // name is later used, which is what the recorded shape stands in for. + const functionHeader = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)/.exec( + segment, + ); + if (functionHeader) { + if (GIT_WORD_PATTERN.test(segment)) { + gitShapedNames.add(functionHeader[1]!); + } + insideDefinition = true; + } + if (insideDefinition) { + // The remaining body segments are definition text, not execution. + if (segment.includes('}')) insideDefinition = false; + continue; + } + // A substitution body executes before the command it is embedded in, in a // subshell of the current directory, so its cwd changes do not escape it. for (const body of substitutions!) { @@ -1678,7 +1819,10 @@ async function evaluateCommandWithCwd( const analysis = analyzeRun(run); switch (analysis.kind) { case 'cd': { - const target = analysis.target; + const target = + analysis.target === undefined + ? undefined + : expandShellLocals(analysis.target, shellLocals); if (analysis.variant === 'popd' || target === undefined) { // `popd`, bare `cd` ($HOME), and dir-stack rotations land the // shell somewhere the daemon cannot resolve statically. @@ -1887,7 +2031,63 @@ async function evaluateCommandWithCwd( case 'all-export': allExport = true; break; + case 'all-export-off': + allExport = false; + break; case 'other': { + // `alias name=body` / `name() { body }` — record, don't execute. + const definition = readDefinition(run); + if (definition) { + definedBodies.set(definition.name, definition.body); + break; + } + const definitionName = readFunctionName(run); + if (definitionName) { + if (run.some((token) => GIT_WORD_PATTERN.test(token.text))) { + gitShapedNames.add(definitionName); + } + break; + } + if (run.length === 1 && gitShapedNames.has(run[0]!.text)) { + const denial = await evaluateUnrecognizedRun( + [run[0]!, { text: 'git', dynamic: false }], + analysis.state, + trackedCwd, + entryCwd, + activeContext(), + scope.relink, + ); + if (denial) return { denial, cwdAfter: trackedCwd }; + break; + } + const body = + run.length > 0 ? definedBodies.get(run[0]!.text) : undefined; + if (body !== undefined) { + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { + return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; + } + // The body runs here, at the cwd this word was reached with. + const nested = await evaluateCommandWithCwd( + body, + trackedCwd, + entryCwd, + activeContext(), + depth + 1, + { relink: scope.relink, locals: shellLocals }, + ); + if (nested.denial) { + return { denial: nested.denial, cwdAfter: trackedCwd }; + } + break; + } + if ( + run.some((t) => PATH_EXTRACTING_PROGRAMS.has(executableBaseName(t))) + ) { + // An archive can place a symlink anywhere below the extraction + // directory, so the directory itself is what became suspect. + if (trackedCwd === undefined) scope.relink.gitDir = true; + else relinkedTargets.push(trackedCwd); + } if ( run.some((t) => PATH_RELINKING_PROGRAMS.has(executableBaseName(t))) ) { From a1652cce78fd8f2dbf7d105f8a7cc77d9a32853e Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 10 Aug 2026 12:32:16 +0800 Subject: [PATCH 21/45] fix(daemon): contain a sub-agent to an in-project agent worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentTool` with `isolation: 'worktree'` provisions under `/.qwen/worktrees/`, which is inside the session — so the acceptance rule's "inside the effective working directory" branch left the boundary alone and one sub-agent could still reach into a sibling's worktree, the very thing this PR is named for. A reported directory that is a checkout root in its own right now becomes the boundary wherever it lives, not only under the session-owned worktree tree. An ordinary subdirectory resolves to the session's own repository and changes nothing, which is what keeps `cd packages/cli && git commit` working. Also drops the unread `entryCwd` parameter from `evaluateUnrecognizedRun`, whose signature implied containment behaviour that function never had, and covers the child-side `invocationCwd` forwarding with a direct test — it is the only link between a pinned sub-agent's real execution directory and the daemon's check. --- .../cli/src/acp-integration/acpAgent.test.ts | 30 +++++++++++++ .../serve/daemon-git-worktree-guard.test.ts | 43 +++++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 35 ++++++++++++--- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0bce06c41de..755b175df16 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -18860,6 +18860,36 @@ describe('createManagedExternalToolGuard', () => { expect(extMethod).toHaveBeenCalledOnce(); }); + // The only link between a worktree-pinned sub-agent's real execution + // directory and the daemon's containment check. + it('forwards the invocation directory to the daemon', async () => { + const extMethod = vi.fn().mockResolvedValue({ allowed: true }); + const guard = createManagedExternalToolGuard({ + extMethod, + } as unknown as AgentSideConnection); + + await expect( + guard({ + callId: 'call-1', + toolName: 'run_shell_command', + args: { command: 'git status' }, + signal: new AbortController().signal, + sessionId: 'session-1', + cwd: '/work/agent-worktree', + }), + ).resolves.toEqual({ allowed: true }); + expect(extMethod).toHaveBeenCalledWith( + SERVE_CONTROL_EXT_METHODS.externalToolGuardPrepare, + { + sessionId: 'session-1', + toolCallId: 'call-1', + toolName: 'run_shell_command', + arguments: { command: 'git status' }, + invocationCwd: '/work/agent-worktree', + }, + ); + }); + // `monitor` spawns its `command` through the same shell, so the built-in // daemon policy has to see it too. it('routes monitor commands to the daemon without an external provider', async () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 1eccb5c0da7..7a190983af7 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1442,6 +1442,49 @@ it -C ${outsideRepo} reset --hard`, }); }); + it('contains a sub-agent to an in-project agent worktree', async () => { + // `AgentTool` with `isolation: 'worktree'` provisions under + // `/.qwen/worktrees/`, i.e. inside the session — being + // inside is not enough to leave the boundary alone. + const agentWorktree = path.join( + effectiveCwd, + '.qwen', + 'worktrees', + 'agent-abc1234', + ); + const sibling = path.join( + effectiveCwd, + '.qwen', + 'worktrees', + 'agent-def5678', + ); + await mkdir(path.join(agentWorktree, 'src'), { recursive: true }); + await mkdir(sibling, { recursive: true }); + await writeFile( + path.join(agentWorktree, '.git'), + `gitdir: ${path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234')}\n`, + ); + await mkdir( + path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234'), + { recursive: true }, + ); + await writeFile( + path.join(outsideRepo, '.git', 'worktrees', 'agent-abc1234', 'gitdir'), + `${path.join(agentWorktree, '.git')}\n`, + ); + + const guard = createDaemonToolGuard(); + // Work inside its own worktree is allowed... + await expect( + guard(call('cd src && git commit -m x', agentWorktree)), + ).resolves.toEqual({ allowed: true }); + // ...reaching into a sibling agent's worktree is not, even though both + // sit inside the session's directory. + await expect( + guard(call(`git -C ${sibling} reset --hard`, agentWorktree)), + ).resolves.toMatchObject({ allowed: false }); + }); + it('contains a sub-agent to the worktree it reports', async () => { // A session id unique to this test: `getWorktreesDir` resolves under the // user's global Qwen dir, so a shared id would have this test create and diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 9785fb3012b..4e3d9e5b196 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1610,7 +1610,6 @@ async function evaluateUnrecognizedRun( run: GuardToken[], state: PrefixState, basisCwd: string | undefined, - entryCwd: string | undefined, context: GuardEvaluationContext, relink?: RelinkState, ): Promise { @@ -1990,7 +1989,6 @@ async function evaluateCommandWithCwd( expanded, analysis.state, trackedCwd, - entryCwd, inherited, scope.relink, ); @@ -2021,7 +2019,6 @@ async function evaluateCommandWithCwd( run, analysis.state, trackedCwd, - entryCwd, activeContext(), scope.relink, ); @@ -2053,7 +2050,6 @@ async function evaluateCommandWithCwd( [run[0]!, { text: 'git', dynamic: false }], analysis.state, trackedCwd, - entryCwd, activeContext(), scope.relink, ); @@ -2138,7 +2134,6 @@ async function evaluateCommandWithCwd( run, analysis.state, trackedCwd, - entryCwd, activeContext(), scope.relink, ); @@ -2195,7 +2190,35 @@ async function evaluateBuiltInGuard( const reportedCwd = request.invocationCwd; if (typeof reportedCwd === 'string' && reportedCwd.length > 0) { const canonicalReported = await realpathNearestExistingAsync(reportedCwd); - if (!isWithinRoot(canonicalReported, sessionCwd)) { + if (isWithinRoot(canonicalReported, sessionCwd)) { + // `AgentTool` with `isolation: 'worktree'` provisions under + // `/.qwen/worktrees/`, which is inside the session — so + // "inside" is not enough to leave the boundary alone. A reported + // directory that is a checkout root in its own right is the sub-agent's + // worktree, and containing it there is what stops one sub-agent from + // reaching into a sibling's. An ordinary subdirectory resolves to the + // session's own repository and changes nothing. + if (canonicalReported !== sessionCwd) { + let discovered: string | undefined; + try { + discovered = await resolveDiscoveredRepository( + canonicalReported, + sessionCwd, + ); + } catch { + return denyTarget( + UNVERIFIABLE_SCOPE_DENIAL_PREFIX, + canonicalReported, + ); + } + if ( + discovered !== undefined && + (await realpathNearestExistingAsync(discovered)) === canonicalReported + ) { + canonicalEffectiveCwd = canonicalReported; + } + } + } else { const ownedWorktrees = await realpathNearestExistingAsync( GitWorktreeService.getWorktreesDir(request.sessionId), ); From 581d2bb1532512e8fe2472091f039cb82fdb339c Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 10 Aug 2026 12:55:56 +0800 Subject: [PATCH 22/45] test(acp): assert the session identity and cwd the guard now receives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `sessionId`/`cwd` to the guard context in `Session.runTool` changed the shape these two assertions pin, and I ran the guard, acpAgent and serve suites but not this one — CI caught what I should have. --- packages/cli/src/acp-integration/session/Session.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 1e509b2d55b..ccd10cc4f52 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -17669,6 +17669,10 @@ describe('Session', () => { toolName: 'read_file', args: { path: '/normalized/final.txt' }, signal: expect.any(AbortSignal), + // The daemon policy falls back to the session and needs to know + // where the tool will run. + sessionId: 'test-session-id', + cwd: process.cwd(), }); expect(executeSpy).not.toHaveBeenCalled(); expect( @@ -17733,6 +17737,10 @@ describe('Session', () => { toolName: 'read_file', args: { path: '/normalized/final.txt' }, signal: expect.any(AbortSignal), + // The daemon policy falls back to the session and needs to know + // where the tool will run. + sessionId: 'test-session-id', + cwd: process.cwd(), }); expect(executeSpy).toHaveBeenCalledOnce(); }); From 6b755d13f7fdd2f00b1225cfe3da83c5edf6e8b1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 10 Aug 2026 15:05:01 +0800 Subject: [PATCH 23/45] fix(daemon): record ordinary targets a dynamic relinker re-points The dynamic-program branch resolved the operands of an unreadable program word but only used them to raise the `.git` flag, so an ordinary target was never added to the relink set: `X=ln; $X -s src && git -C src reset --hard` validated `src` against what it pointed at before the same command replaced it. Ordinary operands are now recorded alongside the `.git` case. Verified load-bearing: dropping the new line fails the added regression test and nothing else. --- .../src/serve/daemon-git-worktree-guard.test.ts | 14 ++++++++++++++ .../cli/src/serve/daemon-git-worktree-guard.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 7a190983af7..a905e4e2006 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1294,6 +1294,20 @@ it -C ${outsideRepo} reset --hard`, }); }); + // A dynamic program word may be `ln`, and an ordinary target it re-points + // is just as invalidating as a `.git` one. + it.each([ + () => + `rm -rf src && X=ln; $X -s ${outsideRepo} src && git -C src reset --hard`, + () => `X=ln; $X -s ${outsideRepo} nested && git -C nested reset --hard`, + ])('denies Git after a dynamic relinker re-points its path %#', async (b) => { + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }); + // Git discovers its repository by walking up even with no relocation. it('denies a planted gitfile at the session root', async () => { const decoyRoot = path.join(temporaryRoot, 'decoy-session'); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 4e3d9e5b196..b0d72d24118 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1950,7 +1950,10 @@ async function evaluateCommandWithCwd( expandShellLocals(token, shellLocals), ); // The program word is unreadable, so it may be `ln`: record its - // operands as possibly re-pointed (`X=ln; $X -s /.git .git`). + // operands as possibly re-pointed. A `.git` among them redirects + // discovery for everything after it; an ordinary one still has to + // be recorded, or a later `git -C ` is validated against + // what the path pointed at before the command replaced it. for (const operand of expanded) { if (operand.text.startsWith('-') || operand.dynamic) continue; if (trackedCwd === undefined) { @@ -1959,6 +1962,7 @@ async function evaluateCommandWithCwd( } const resolved = path.resolve(trackedCwd, operand.text); if (path.basename(resolved) === '.git') scope.relink.gitDir = true; + else relinkedTargets.push(resolved); } if ( analysis.state.unresolved || From 0f970d47fd189c759e21574195e1e445342f86a6 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 10 Aug 2026 21:18:48 +0800 Subject: [PATCH 24/45] fix(daemon): close the round-7 escapes, verified with the reported payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewers were right that my previous round's "denies as written" replies were built on non-equivalent counter-probes: a `.git` inside the path, or a literal `-C`, tripped an unrelated marker rule while the reported mechanism went untouched. Re-run with each payload verbatim — against a path with no Git word in it — five of them were allowed. All are fixed, and the new tests use those exact payloads. - `$'…'` is ANSI-C quoting only OUTSIDE double quotes, so the substitution in `echo "$'$(GIT_DIR=/.git git reset --hard HEAD~1)'"` is live. Both scanners now gate the skip on `!single && !double`. - The export attribute sticks to the name: after `export GIT_DIR`, a LATER assignment to it reaches the git subprocess. Name-only exports of a relocation key are recorded so those assignments count as exported. - Both sides of a pipe run in subshells, so a pipe-side `cd` must not move the shell. Segments that are pipeline components restore the directory they started with; the top-level separators are read with the same quoting rules `splitCommands` uses, and any disagreement falls back to treating every segment of a piped command as a component. - A bare digit before a *spaced* redirect is a real argv word, not a file descriptor, and nothing in the token stream distinguishes it — so it is marked ambiguous and a payload built from it fails closed instead of silently dropping the word. - `-o`/`-O` before `c` in a short bundle does not cancel the `c`: bash still executes it, taking the command from a later argv entry. The bundle is now parsed for how many entries the value flags consume on either side. - Rebuilding a command line out of separate argv words re-quotes anything that would otherwise split, so a path with a space stays one word and a `-C` value cannot shrink. `eval` keeps the verbatim join it needs, since it re-parses its argument as shell text. --- .../serve/daemon-git-worktree-guard.test.ts | 55 ++++++ .../src/serve/daemon-git-worktree-guard.ts | 172 ++++++++++++++++-- 2 files changed, 213 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index a905e4e2006..bd83e1a3c62 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1584,6 +1584,61 @@ it -C ${outsideRepo} reset --hard`, } }); + // Round-7. These are the reviewers' exact payloads: the earlier "denies as + // written" replies were checked with counter-probes that tripped a + // different rule (a `.git` in the path matching the Git-word marker, or a + // literal `-C`), leaving the reported mechanism untouched. `outsideRepo` + // would do that again, so these use a path with no Git word in it. + describe('round-7 exact payloads', () => { + const plainOutside = path.join(temporaryRoot, 'elsewhere', 'checkout'); + const spacedOutside = path.join(temporaryRoot, 'boundary with space'); + + it.each([ + // `$'…'` is not ANSI-C quoting inside double quotes, so the + // substitution in it is live. + () => `echo "$'$(GIT_DIR=${plainOutside}/.git git reset --hard HEAD~1)'"`, + // The export attribute sticks to the name, so a LATER assignment to it + // reaches the git subprocess. + () => `export GIT_DIR; GIT_DIR=${plainOutside}; git reset --hard`, + () => + `export GIT_WORK_TREE; GIT_WORK_TREE=${plainOutside}; git reset --hard`, + // Both sides of a pipe run in subshells: the parent stays outside. + () => `cd ${plainOutside}; echo x | cd ${effectiveCwd}; git commit -m x`, + // A bare digit before a spaced redirect is a real argv word. + () => `eval git -C 2 > x reset --hard`, + // `-o` before `c` in a bundle does not cancel the `c`. + () => `bash -oc errexit "$P"`, + () => `bash -Oc extglob "$P"`, + () => `bash -oc errexit 'git -C ${plainOutside} reset --hard'`, + // Re-joining argv must not lose the quoting that made a path one word. + () => `env -S 'git -C' '${spacedOutside}' reset --hard`, + ])('denies the reported payload verbatim %#', async (build) => { + await mkdir(path.join(plainOutside, '.git'), { recursive: true }); + await mkdir(path.join(spacedOutside, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves the equivalent in-boundary shapes alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'echo x | cat; git commit -m x', + `cd nested; echo x | cd ${effectiveCwd}; git commit -m x`, + "env -S 'git status'", + "bash -oc errexit 'git status'", + 'export GIT_DIR; echo done', + ]) { + await expect(guard(request(command))).resolves.toEqual({ + allowed: true, + }); + } + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index b0d72d24118..d35743e61a6 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -130,11 +130,17 @@ const SHELL_WRAPPER_VALUE_FLAGS = new Set(['-o', '-O']); // the bundle consumes the rest of it and `-c` is not present. function shellBundleRequestsCommand(flag: string): boolean { if (!flag.startsWith('-') || flag.startsWith('--')) return false; + return flag.slice(1).includes('c'); +} + +/** How many argv entries the value-taking flags before `c` consume. */ +function shellBundleValueFlagsBeforeCommand(flag: string): number { + let consumed = 0; for (const character of flag.slice(1)) { - if (character === 'o' || character === 'O') return false; - if (character === 'c') return true; + if (character === 'c') return consumed; + if (character === 'o' || character === 'O') consumed++; } - return false; + return consumed; } const ENV_CHDIR_FLAGS = new Set(['-C', '--chdir']); @@ -247,6 +253,9 @@ interface GuardToken { // relocation markers — a here-string carries a whole command — but it is // never argv, so payload joins (`eval …`, `env -S …`) must skip it. readonly redirect?: boolean; + // A bare digit before a redirection: a file descriptor or an argv word, + // indistinguishable here. + readonly ambiguousFd?: boolean; } interface GitEnvRelocation { @@ -447,7 +456,10 @@ function tokenizeSegment( const tokens = runs.at(-1)!.tokens; const previous = tokens.at(-1); if (previous && !previous.redirect && /^\d+$/.test(previous.text)) { - tokens[tokens.length - 1] = { ...previous, redirect: true }; + // `2>file` makes it a file descriptor, `git -C 2 > file` makes it a + // real argv word, and the token stream cannot tell them apart — so + // it is marked ambiguous and the analysis fails closed. + tokens[tokens.length - 1] = { ...previous, ambiguousFd: true }; } redirectOperand = true; continue; @@ -483,6 +495,17 @@ function expandShellLocals( return { text, dynamic: !resolved }; } +// Rebuilding a payload from tokens loses the quoting that made a value one +// argv word, so a path with a space would re-parse as several words and the +// `-C` value would silently shrink. Re-quote anything that would split. +function quoteForRejoin(text: string): string { + if (text.length === 0) return "''"; + if (!/[\s"'`$\\|&;<>()*?[\]{}!#~]/.test(text)) return text; + return `'${text.replaceAll("'", `'\\''`)}'`; +} + +// `eval` concatenates its arguments and re-parses the result as shell text, +// so its payload must be joined verbatim. function joinTokenTexts(tokens: GuardToken[]): string { return tokens .filter((token) => !token.redirect) @@ -490,6 +513,17 @@ function joinTokenTexts(tokens: GuardToken[]): string { .join(' '); } +// Rebuilding a command line out of separate argv words is the opposite case: +// a value that was one word only because it was quoted has to stay one word, +// or a path with a space re-parses as several and a `-C` value silently +// shrinks. +function joinArgvTexts(tokens: GuardToken[]): string { + return tokens + .filter((token) => !token.redirect) + .map((token) => quoteForRejoin(token.text)) + .join(' '); +} + function hasGitRelocationMarker(tokens: GuardToken[]): boolean { return tokens.some((token) => { if (token.text === '-C' || token.text.startsWith('-C')) return true; @@ -625,7 +659,7 @@ function consumeEnvWrapper( // Mirrors the `-c` payload rule: a payload the daemon cannot read is // undecidable, not absent. if (payloadToken.dynamic) return { next: run.length, undecidable: true }; - const rest = joinTokenTexts(run.slice(index + 2)); + const rest = joinArgvTexts(run.slice(index + 2)); return { next: run.length, payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, @@ -636,7 +670,7 @@ function consumeEnvWrapper( if (/^-[A-Za-z]*S/.test(token.text) && !token.text.startsWith('--')) { const fused = token.text.slice(token.text.indexOf('S') + 1); if (fused.length > 0) { - const rest = joinTokenTexts(run.slice(index + 1)); + const rest = joinArgvTexts(run.slice(index + 1)); return { next: run.length, payload: rest ? `${fused} ${rest}` : fused, @@ -647,7 +681,7 @@ function consumeEnvWrapper( const payloadToken = run[index + 1]; if (payloadToken === undefined) return { next: run.length }; if (payloadToken.dynamic) return { next: run.length, undecidable: true }; - const rest = joinTokenTexts(run.slice(index + 2)); + const rest = joinArgvTexts(run.slice(index + 2)); return { next: run.length, payload: rest ? `${payloadToken.text} ${rest}` : payloadToken.text, @@ -799,7 +833,12 @@ function consumeShellWrapper( return { kind: 'static', payload: remainder }; } let payloadIndex = nextArgvIndex(run, index + 1); - if (/[oO]/.test(remainder)) { + // `-o`/`-O` on either side of the `c` each consume one argv entry + // before the command string. + let toSkip = + shellBundleValueFlagsBeforeCommand(token.text) + + (/[oO]/.test(remainder) ? 1 : 0); + while (toSkip-- > 0) { payloadIndex = nextArgvIndex(run, payloadIndex + 1); } const payloadToken = run[payloadIndex]; @@ -976,7 +1015,7 @@ function readDefinition( .slice(first.endsWith('()') ? 1 : 2) .filter((token) => token.text !== '{' && token.text !== '}'); if (bodyTokens.length === 0) return undefined; - return { name, body: joinTokenTexts(bodyTokens) }; + return { name, body: joinArgvTexts(bodyTokens) }; } function analyzeRun(run: GuardToken[]): RunAnalysis { @@ -1038,7 +1077,11 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { } if (program === 'eval') { const payloadTokens = run.slice(index + 1); - if (payloadTokens.some((payloadToken) => payloadToken.dynamic)) { + if ( + payloadTokens.some( + (payloadToken) => payloadToken.dynamic || payloadToken.ambiguousFd, + ) + ) { return { kind: 'undecidable' }; } return { @@ -1530,7 +1573,7 @@ function extractCommandSubstitutions(segment: string): string[] | null { index += 2; continue; } - if (!single && character === '$' && segment[index + 1] === "'") { + if (!single && !double && character === '$' && segment[index + 1] === "'") { index = skipAnsiCQuote(segment, index); continue; } @@ -1576,7 +1619,7 @@ function findSubstitutionEnd(segment: string, start: number): number { index++; continue; } - if (!single && character === '$' && segment[index + 1] === "'") { + if (!single && !double && character === '$' && segment[index + 1] === "'") { index = skipAnsiCQuote(segment, index) - 1; continue; } @@ -1676,6 +1719,76 @@ interface EvaluationScope { readonly locals?: Map; } +/** + * The top-level separators `splitCommands` cut on, in order — mirroring its + * quote and substitution rules. `separators[i]` follows segment `i`. Both + * sides of a `|` run in subshells, so a `cd` there must not move the shell. + */ +function readTopLevelSeparators(command: string): string[] { + const separators: string[] = []; + let single = false; + let double = false; + let backtick = false; + let substitution = 0; + const quoteStack: Array<[boolean, boolean]> = []; + for (let index = 0; index < command.length; index++) { + const character = command[index]!; + const next = command[index + 1]; + if (!single && character === '\\' && index + 1 < command.length) { + index++; + continue; + } + if (!single && character === '`') { + backtick = !backtick; + continue; + } + if (!single && !backtick && character === '$' && next === '(') { + quoteStack.push([single, double]); + single = false; + double = false; + substitution++; + index++; + continue; + } + if ( + !backtick && + substitution > 0 && + character === ')' && + !single && + !double + ) { + const enclosing = quoteStack.pop(); + single = enclosing?.[0] ?? false; + double = enclosing?.[1] ?? false; + substitution--; + continue; + } + if (!backtick && character === "'" && !double) { + single = !single; + continue; + } + if (!backtick && character === '"' && !single) { + double = !double; + continue; + } + if (single || double || backtick || substitution > 0) continue; + if (character === '&' && next === '&') { + separators.push('&&'); + index++; + } else if (character === '|' && next === '|') { + separators.push('||'); + index++; + } else if (character === '|') { + separators.push('|'); + } else if (character === ';') { + separators.push(';'); + } else if (character === '\n') { + separators.push('\n'); + } + } + return separators; +} + interface CommandEvaluation { readonly denial?: GuardDenial; readonly cwdAfter: string | undefined; @@ -1705,6 +1818,9 @@ async function evaluateCommandWithCwd( // `alias g='git …'` and `f() { git …; }` both make a later bare word run a // body defined earlier; without them that word is an opaque `other` run. const definedBodies = new Map(); + // Names carrying the export attribute from a name-only `export KEY`; a + // later assignment to one of them reaches the git subprocess. + const exportedNames = new Set(); // Function bodies that `splitCommands` cut across segments cannot be // replayed verbatim, so the name is recorded as Git-shaped instead and the // later bare word answers to the unrecognized-program containment rule. @@ -1755,7 +1871,18 @@ async function evaluateCommandWithCwd( for (const [key, token] of snapshot.locals) shellLocals.set(key, token); }; const subshellCwds: ShellStateSnapshot[] = []; - for (const segment of splitCommands(command)) { + const segments = splitCommands(command); + const separators = readTopLevelSeparators(command); + // On any disagreement with `splitCommands`, treat every segment of a piped + // command as a pipeline component rather than guessing. + const separatorsMatch = separators.length === segments.length - 1; + const isPipeComponent = (index: number): boolean => + separatorsMatch + ? separators[index - 1] === '|' || separators[index] === '|' + : separators.includes('|'); + for (const [segmentIndex, segment] of segments.entries()) { + const pipeComponent = isPipeComponent(segmentIndex); + const cwdBeforeSegment = trackedCwd; const substitutions = extractCommandSubstitutions(segment); const tokenized = substitutions === null ? null : tokenizeSegment(segment, subshellDepth); @@ -2008,7 +2135,9 @@ async function evaluateCommandWithCwd( exported.relocations.push(...analysis.state.relocations); if (analysis.state.unresolved) exported.unresolved = true; // `export GIT_DIR` with no `=` exports whatever an earlier - // shell-local assignment left in that name. + // shell-local assignment left in that name — and, because the + // export *attribute* sticks to the name, whatever a later one puts + // there as well. for (const operand of analysis.operands) { if (leadingEnvAssignmentKey(operand.text) !== null) continue; if (operand.dynamic) { @@ -2018,6 +2147,14 @@ async function evaluateCommandWithCwd( } const pending = shellLocals.get(operand.text); if (pending) recordEnvAssignment(pending, exported); + if ( + GIT_DIR_ENV_KEYS.has(operand.text) || + GIT_WORK_TREE_ENV_KEYS.has(operand.text) || + GIT_UNRESOLVABLE_ENV_KEYS.has(operand.text) || + GIT_PROGRAM_ENV_KEYS.has(operand.text) + ) { + exportedNames.add(operand.text); + } } const denial = await evaluateUnrecognizedRun( run, @@ -2118,6 +2255,10 @@ async function evaluateCommandWithCwd( for (const token of run) { const key = leadingEnvAssignmentKey(token.text); if (key === null) continue; + if (exportedNames.has(key)) { + recordEnvAssignment(token, exported); + continue; + } const previous = shellLocals.get(key); if (!isAppendAssignment(token.text) || previous === undefined) { shellLocals.set(key, token); @@ -2162,6 +2303,9 @@ async function evaluateCommandWithCwd( subshellCwds.push(snapshotShellState()); subshellDepth++; } + // Both sides of a pipe run in their own subshell, so whatever this + // segment did to the shell's directory dies with it. + if (pipeComponent) trackedCwd = cwdBeforeSegment; } return { cwdAfter: trackedCwd, From 8e46dbaa9d17b2e9ee13ddd08c374ac6deca6437 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 08:23:43 +0800 Subject: [PATCH 25/45] fix(daemon): repair the round-7 patch and bound what this guard promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves. First, the round-7 patch introduced seven defects of its own, six of them reproduced here before fixing: - the fd digit of `2>…` was eligible as a `-c` payload, because `nextArgvIndex` skipped only redirect-flagged tokens; - `o`/`O` letters after `c` in a bundle were counted by presence rather than per letter, shifting the extracted payload left; - `env --split-string=` still rebuilt its payload with the verbatim join while both sibling branches had moved to the re-quoting one; - the separator scan recorded no lone `&` and mistook `>|` for a pipe, and its disagreement fallback scoped nothing instead of everything; - the export-attribute set neither crossed `eval` nor rolled back with a subshell; - deferred alias and function bodies were keyed on `run[0]` rather than on the program word, so any prefix hid them. Second, and more important than any single rule: the docs now bound what this control claims. It is reliable against Git relocation written in the literal forms the design doc lists — the mis-targeted command it exists for — and best-effort, not a boundary, against shell text written to defeat it. Seven rounds of adversarial review support that framing rather than contradict it: each round closed the reported bypasses and the next found more, several inside the rules the previous round added. The gap is structural — the guard reads command text before a shell interprets it — so the honest fix is to move the decision off the text, deciding where a command may write when it runs rather than predicting it beforehand. That is a separate change with its own design, and this one should not grow into it by accretion. Saying so plainly is itself a safety property: an operator who believes the daemon cannot reach a sibling worktree would grant it more trust than the mechanism earns. --- docs/design/daemon-git-worktree-guard.md | 33 +++++++++ docs/users/qwen-serve.md | 7 +- .../serve/daemon-git-worktree-guard.test.ts | 43 ++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 70 +++++++++++++++---- 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index 9d34552a0db..2a4c41c49dc 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -205,6 +205,39 @@ not tracked across commands, and program words outside the unwrapped set are handled by failing closed on Git-shaped runs rather than by modelling their execution semantics. +### Why this cannot be made complete here + +The guard decides by reading command **text** before a shell interprets it, +and that gap is structural rather than a list of unfixed cases. Seven rounds +of adversarial review on this change bear it out: each round closed the +reported bypasses and each following round found more, several of them in the +rules added by the round before. The parser is now several times the size of +the policy it protects, and the shell's semantics — quoting modes, expansion +order, subshell boundaries, deferred bodies, environment attributes — remain +larger than any token scan of them. + +So the promise here is deliberately bounded: + +- **Reliable** against Git relocation written in the literal forms this + document lists. That is the case the control exists for: an agent that + mis-targets a sibling checkout, a stale `-C`, a `cd` that outlived its + purpose. +- **Best-effort, not a boundary**, against shell text written to defeat it. + Constructions that hide the relocation from a static reader — variable + indirection, generated payloads, exotic quoting, program words the daemon + cannot model — may pass. New ones will keep being found. + +Treating it as more than that would be the actual risk: an operator who +believes the daemon cannot mutate a sibling worktree will grant it broader +trust than the mechanism earns. + +Closing the gap properly means moving the decision off the text. The +enforcement point, not the parser, is what would converge — deciding where a +command may write when it runs (a restricted working directory, a mount or +namespace view, or interception at the Git invocation rather than the shell +line) instead of predicting it beforehand. That is a separate change with its +own design; this one should not grow into it by accretion. + ## Non-goals - No changes to core `ShellTool`, `ShellToolInvocation`, shell AST parsing, diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index bc436f2730b..c58a5592c41 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -485,7 +485,12 @@ or unresolvable repository location, and as parsed, its payload could not be resolved, or an unrecognized program may run a relocated Git command. -The guard is a static best-effort policy: it does not interpret script files, +The guard is reliable against Git relocation written in the literal forms +above — the mis-targeted command this control exists for — and is +**best-effort, not a boundary**, against shell text written to defeat it: +constructions that hide the relocation from a static reader may pass, and new +ones will keep being found. Do not grant a daemon broader trust on the +strength of it. It does not interpret script files, track environment variable values across commands, or analyze heredoc bodies (Git-shaped text inside a heredoc can be denied even though the shell never executes it). `/fork` and agent-backed workspace memory remember/dream remain diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index bd83e1a3c62..e2346acadf9 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -18,6 +18,9 @@ const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'daemon-guard-')); const effectiveCwd = path.join(temporaryRoot, 'workspace', 'worktree'); const insideNested = path.join(effectiveCwd, 'nested'); const outsideRepo = path.join(temporaryRoot, 'outside', 'repo'); +// A second outside checkout whose path contains no Git word, so a test using +// it cannot pass because `\bgit\b` happened to match inside the path. +const plainOutsidePath = path.join(temporaryRoot, 'elsewhere', 'checkout'); mkdirSync(path.join(outsideRepo, '.git'), { recursive: true }); mkdirSync(insideNested, { recursive: true }); @@ -1639,6 +1642,46 @@ it -C ${outsideRepo} reset --hard`, }); }); + // Defects the round-7 patch itself introduced. Each was reproduced before + // the fix; the path deliberately carries no Git word. + it.each([ + // The fd digit of `2>…` belongs to the redirection, never to argv. + () => `sh -c 2> /dev/null 'git -C ${plainOutsidePath} reset --hard'`, + // Each `o`/`O` after `c` consumes one entry, not "one if any". + () => `bash -coo x y 'git -C ${plainOutsidePath} reset --hard'`, + // The last payload rebuild that still joined without re-quoting. + () => `env --split-string='git -C' '${plainOutsidePath}' reset --hard`, + // A lone `&` backgrounds into a subshell, so its `cd` does not stick. + () => `cd ${plainOutsidePath}; cd ${effectiveCwd} & git reset --hard`, + // `>|` is the clobber redirect, not a pipe. + () => `cd ${plainOutsidePath} >| /tmp/f && git -C . push`, + // The export attribute is shell state and crosses `eval`. + () => + `export GIT_DIR; eval 'GIT_DIR=${plainOutsidePath}'; git reset --hard`, + // A deferred body is keyed on the program word, not on run[0]. + () => `alias g='git reset --hard'; cd ${plainOutsidePath}; X=1 g`, + ])('closes a defect the round-7 patch introduced %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves backgrounded and redirected in-boundary work alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'sleep 1 & git commit -m x', + 'git status > out.txt 2> err.txt', + "bash -coo x y 'git status'", + "env --split-string='git status'", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index d35743e61a6..c47ccd6ca52 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -700,7 +700,7 @@ function consumeEnvWrapper( ) { if (token.text.startsWith('--split-string=')) { const fused = token.text.slice('--split-string='.length); - const rest = joinTokenTexts(run.slice(index + 1)); + const rest = joinArgvTexts(run.slice(index + 1)); return { next: run.length, payload: rest ? `${fused} ${rest}` : fused }; } index++; @@ -796,7 +796,12 @@ type ShellWrapperScan = // (`sh -c > /dev/null 'cmd'`) is not the payload. function nextArgvIndex(run: GuardToken[], from: number): number { let index = from; - while (index < run.length && run[index]!.redirect) index++; + while ( + index < run.length && + (run[index]!.redirect || run[index]!.ambiguousFd) + ) { + index++; + } return index; } @@ -837,7 +842,7 @@ function consumeShellWrapper( // before the command string. let toSkip = shellBundleValueFlagsBeforeCommand(token.text) + - (/[oO]/.test(remainder) ? 1 : 0); + (remainder.match(/[oO]/g)?.length ?? 0); while (toSkip-- > 0) { payloadIndex = nextArgvIndex(run, payloadIndex + 1); } @@ -978,6 +983,19 @@ function disablesAllExport(run: GuardToken[], start: number): boolean { * `alias name='body'` and `name() { body; }` both defer a command: the body * runs where the *later* bare word appears, not where it was written. */ +/** + * The word that actually names the program, i.e. the first token that is not + * a leading assignment or a shell keyword. `X=1 g` runs `g`. + */ +function readProgramWord(run: GuardToken[]): string | undefined { + for (const token of run) { + if (leadingEnvAssignmentKey(token.text) !== null) continue; + if (LEADING_SHELL_KEYWORDS.has(token.text)) continue; + return token.text; + } + return undefined; +} + /** `f()` / `f ()` — the header of a function definition, if this is one. */ function readFunctionName(run: GuardToken[]): string | undefined { if (run.length === 0) return undefined; @@ -1717,6 +1735,9 @@ interface EvaluationScope { // Shell variables the nested command can see: `eval` and subshells inherit // them, a `sh -c` subprocess does not. readonly locals?: Map; + // Names carrying the export attribute, shared with `eval` for the same + // reason its locals are. + readonly exportedNames?: Set; } /** @@ -1775,11 +1796,15 @@ function readTopLevelSeparators(command: string): string[] { if (character === '&' && next === '&') { separators.push('&&'); index++; + } else if (character === '&') { + // A lone `&` backgrounds the command in its own subshell. + separators.push('&'); } else if (character === '|' && next === '|') { separators.push('||'); index++; } else if (character === '|') { - separators.push('|'); + // `>|` is the clobber redirect, not a pipe. + separators.push(command[index - 1] === '>' ? '>|' : '|'); } else if (character === ';') { separators.push(';'); } else if (character === '\n') { @@ -1820,7 +1845,7 @@ async function evaluateCommandWithCwd( const definedBodies = new Map(); // Names carrying the export attribute from a name-only `export KEY`; a // later assignment to one of them reaches the git subprocess. - const exportedNames = new Set(); + const exportedNames = scope.exportedNames ?? new Set(); // Function bodies that `splitCommands` cut across segments cannot be // replayed verbatim, so the name is recorded as Git-shaped instead and the // later bare word answers to the unrecognized-program containment rule. @@ -1876,10 +1901,14 @@ async function evaluateCommandWithCwd( // On any disagreement with `splitCommands`, treat every segment of a piped // command as a pipeline component rather than guessing. const separatorsMatch = separators.length === segments.length - 1; + const SUBSHELL_SEPARATORS = new Set(['|', '&']); const isPipeComponent = (index: number): boolean => separatorsMatch - ? separators[index - 1] === '|' || separators[index] === '|' - : separators.includes('|'); + ? SUBSHELL_SEPARATORS.has(separators[index - 1] ?? '') || + SUBSHELL_SEPARATORS.has(separators[index] ?? '') + : // Structural disagreement with `splitCommands`: scope every segment + // rather than guess which ones ran in a subshell. + separators.some((separator) => SUBSHELL_SEPARATORS.has(separator)); for (const [segmentIndex, segment] of segments.entries()) { const pipeComponent = isPipeComponent(segmentIndex); const cwdBeforeSegment = trackedCwd; @@ -1925,7 +1954,11 @@ async function evaluateCommandWithCwd( depth + 1, // A substitution runs in a subshell: it inherits the variables but // its own assignments die with it, so it gets a copy. - { relink: scope.relink, locals: new Map(shellLocals) }, + { + relink: scope.relink, + locals: new Map(shellLocals), + exportedNames: new Set(exportedNames), + }, ); if (nested.denial) { return { denial: nested.denial, cwdAfter: trackedCwd }; @@ -2004,9 +2037,12 @@ async function evaluateCommandWithCwd( depth + 1, { relink: scope.relink, - // `eval` runs in this very shell, so it sees these variables; - // a `sh -c` subprocess inherits only exported ones. - ...(analysis.propagatesCwd ? { locals: shellLocals } : {}), + // `eval` runs in this very shell, so it sees these variables + // and the export attributes; a `sh -c` subprocess inherits only + // exported ones. + ...(analysis.propagatesCwd + ? { locals: shellLocals, exportedNames } + : {}), }, ); if (nested.denial) { @@ -2179,6 +2215,7 @@ async function evaluateCommandWithCwd( definedBodies.set(definition.name, definition.body); break; } + const programToken = readProgramWord(run); const definitionName = readFunctionName(run); if (definitionName) { if (run.some((token) => GIT_WORD_PATTERN.test(token.text))) { @@ -2186,9 +2223,12 @@ async function evaluateCommandWithCwd( } break; } - if (run.length === 1 && gitShapedNames.has(run[0]!.text)) { + if (programToken !== undefined && gitShapedNames.has(programToken)) { const denial = await evaluateUnrecognizedRun( - [run[0]!, { text: 'git', dynamic: false }], + [ + { text: programToken, dynamic: false }, + { text: 'git', dynamic: false }, + ], analysis.state, trackedCwd, activeContext(), @@ -2198,7 +2238,9 @@ async function evaluateCommandWithCwd( break; } const body = - run.length > 0 ? definedBodies.get(run[0]!.text) : undefined; + programToken === undefined + ? undefined + : definedBodies.get(programToken); if (body !== undefined) { if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; From 089c3e380c185687c6bfb4b38992116399c83e67 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 15:32:21 +0800 Subject: [PATCH 26/45] fix(daemon): close the round-9 critical forms an agent may actually emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped to the Critical findings that reproduced against the real guard with the reviewer's payloads verbatim (Git-word-free path). Common shell forms, not adversarial exotica; the parser-edge tail stays under the bounded promise this PR now documents. - `&>`/`&>>` is a redirect operator, no longer read as a background `&`. - `function NAME { … }` (the keyword form, `()` optional) is recognised as a definition. - `git -c include.path=`/`includeIf..path=` pull in a config file the guard cannot read; it can carry a `core.worktree` redirect or executable config, so it is treated as dangerous config and fails closed. - `imap.tunnel`, `instaweb.httpd` join the command-executing config keys, and `GIT_DIFFTOOL_EXTCMD` the executed-env keys. - `GIT_DIR=… set -a` persists (a prefix assignment on the special builtin `set`) and exports; that leading assignment is now carried, not dropped. - alias/function recognition starts at the program word — past a leading redirect (`2>/dev/null alias …`), keyword (`if …; then alias …`) or assignment — and records every pair of a multi-alias statement. - a heredoc body is stdin data, not commands: it is stripped before command splitting so a body `cd` cannot launder the tracked directory. - a function body that `splitCommands` cuts across segments is now captured whole and replayed, so a `-C ` inside it is seen, not just the name. Two round-9 Criticals are deliberately not "fixed" here: `cd & git …` runs git in the parent shell at the in-boundary cwd, so allowing it is correct; and an archive that plants a `.git` for a later path-less discovery is a TOCTOU (the unpack happens after the decision), left to the same limitation as the symlink race rather than denying every `tar && git commit`. --- .../serve/daemon-git-worktree-guard.test.ts | 82 ++++++++ .../src/serve/daemon-git-worktree-guard.ts | 180 ++++++++++++++---- 2 files changed, 222 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index e2346acadf9..1509095e6cc 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1682,6 +1682,88 @@ it -C ${outsideRepo} reset --hard`, } }); + // Common shell forms an agent may emit — not adversarial exotica. Fixed + // even under the guard's "reliable against literal forms" promise. + it.each([ + // `&>` / `&>>` is a redirect operator, not a background separator. + () => `cd ${plainOutsidePath} &> /dev/null; git reset --hard`, + () => `cd ${plainOutsidePath} &>> /dev/null; git reset --hard`, + // The `function NAME { … }` keyword form, `()` optional. + () => `function g { git reset --hard; }; cd ${plainOutsidePath}; g`, + () => `function g() { git reset --hard; }; cd ${plainOutsidePath}; g`, + // `include.path`/`includeIf.*.path` pull in a config file the guard + // cannot read; it can carry a worktree redirect or executable config. + () => `git -c include.path=/tmp/evil reset --hard`, + () => `git -c includeIf.gitdir:/x.path=/tmp/evil commit -m x`, + ])( + 'denies a common-form relocation the parser used to miss %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves the in-boundary equivalents of those forms alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'git status &> /dev/null', + 'git commit -m x &>> log.txt', + 'function g { git status; }; cd nested; g', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + + // Round-9 Criticals reproduced before fixing (Git-word-free path). + it.each([ + // Config/env channels git executes as programs. + () => `git -c imap.tunnel='touch /tmp/x' -C ${plainOutsidePath} fetch`, + () => + `git -c instaweb.httpd='touch /tmp/x' -C ${plainOutsidePath} rev-parse`, + () => + `GIT_DIFFTOOL_EXTCMD='touch /tmp/x' git -C ${plainOutsidePath} difftool`, + // `GIT_DIR=… set -a` persists (special builtin) and exports. + () => `GIT_DIR=${plainOutsidePath}/.git set -a; git reset --hard`, + // Definition recognition behind a redirect / keyword prefix. + () => `2>/dev/null alias g='git reset --hard'; cd ${plainOutsidePath}; g`, + () => + `if true; then alias g='git reset --hard'; fi; cd ${plainOutsidePath}; g`, + // Every pair of a multi-alias statement is a definition. + () => `alias a=x g='git reset --hard'; cd ${plainOutsidePath}; g`, + // A heredoc body must not launder a tracked cwd. + () => + `cd ${plainOutsidePath}; cat < `f() { true; git -C ${plainOutsidePath} reset --hard; }; f`, + ])('denies the round-9 critical form %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps the round-9 in-boundary equivalents alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // A backgrounded `cd` does not move the shell that runs git. + 'cd nested & git commit -m x', + // Extract-then-commit is ordinary work, not a relocation. + 'tar -xf a.tar && git commit -m x', + 'cat < { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index c47ccd6ca52..8941c328d11 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -90,6 +90,13 @@ const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ /^ssh\.variant$/, /^tar\..+\.command$/, /^web\.browser$/, + // Pulls in a config file the guard cannot read: it can carry a + // `core.worktree` redirect or any command-executing key, so it is + // undecidable and fails closed. + /^include\.path$/, + /^includeif\..+\.path$/, + /^imap\.tunnel$/, + /^instaweb\.httpd$/, ]; // Environment assignments that redirect git's repository selection (mirrors @@ -110,6 +117,7 @@ const GIT_UNRESOLVABLE_ENV_KEYS = new Set([ 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_SYSTEM', 'GIT_EDITOR', + 'GIT_DIFFTOOL_EXTCMD', 'GIT_EXTERNAL_DIFF', 'GIT_OBJECT_DIRECTORY', 'GIT_PAGER', @@ -880,8 +888,8 @@ type RunAnalysis = } | { kind: 'dynamic-program'; rest: GuardToken[]; state: PrefixState } | { kind: 'export'; state: PrefixState; operands: GuardToken[] } - | { kind: 'all-export' } - | { kind: 'all-export-off' } + | { kind: 'all-export'; state: PrefixState } + | { kind: 'all-export-off'; state: PrefixState } | { kind: 'undecidable' } | { kind: 'other'; state: PrefixState; assignmentsOnly: boolean }; @@ -996,40 +1004,70 @@ function readProgramWord(run: GuardToken[]): string | undefined { return undefined; } +// The tokens from the program word onward — past leading keywords, +// assignments and redirect/fd operands — so a definition or a call is +// recognised even behind `if …; then`, `X=1`, or `2>/dev/null`. +function runFromProgramWord(run: GuardToken[]): GuardToken[] { + let index = 0; + while (index < run.length) { + const token = run[index]!; + if ( + token.redirect || + token.ambiguousFd || + leadingEnvAssignmentKey(token.text) !== null || + LEADING_SHELL_KEYWORDS.has(token.text) + ) { + index++; + continue; + } + break; + } + return run.slice(index); +} + /** `f()` / `f ()` — the header of a function definition, if this is one. */ function readFunctionName(run: GuardToken[]): string | undefined { - if (run.length === 0) return undefined; - const first = run[0]!.text; + const body = runFromProgramWord(run); + if (body.length === 0) return undefined; + const first = body[0]!.text; if (first.endsWith('()') && first.length > 2) return first.slice(0, -2); - if (run[1]?.text === '()') return first; + if (body[1]?.text === '()') return first; return undefined; } +// R6-5: a single `alias a=1 b=2` statement defines every pair, not just the +// first. Returns all of them. +function readAliasDefinitions( + run: GuardToken[], +): Array<{ name: string; body: string }> { + const body = runFromProgramWord(run); + if (body.length === 0 || executableBaseName(body[0]!) !== 'alias') return []; + const definitions: Array<{ name: string; body: string }> = []; + for (const token of body.slice(1)) { + const separator = token.text.indexOf('='); + if (separator <= 0) continue; + definitions.push({ + name: token.text.slice(0, separator), + body: token.text.slice(separator + 1), + }); + } + return definitions; +} + function readDefinition( run: GuardToken[], ): { name: string; body: string } | undefined { - if (run.length === 0) return undefined; - const program = executableBaseName(run[0]!); - if (program === 'alias') { - for (const token of run.slice(1)) { - const separator = token.text.indexOf('='); - if (separator <= 0) continue; - return { - name: token.text.slice(0, separator), - body: token.text.slice(separator + 1), - }; - } - return undefined; - } + const body = runFromProgramWord(run); + if (body.length === 0) return undefined; // shell-quote yields `f()` (or `f` `()`), then the braced body tokens. - const first = run[0]!.text; + const first = body[0]!.text; const name = first.endsWith('()') ? first.slice(0, -2) - : run[1]?.text === '()' + : body[1]?.text === '()' ? first : undefined; if (!name) return undefined; - const bodyTokens = run + const bodyTokens = body .slice(first.endsWith('()') ? 1 : 2) .filter((token) => token.text !== '{' && token.text !== '}'); if (bodyTokens.length === 0) return undefined; @@ -1068,8 +1106,12 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { return { kind: 'export', state, operands }; } if (program === 'set') { - if (requestsAllExport(run, index + 1)) return { kind: 'all-export' }; - if (disablesAllExport(run, index + 1)) return { kind: 'all-export-off' }; + if (requestsAllExport(run, index + 1)) { + return { kind: 'all-export', state }; + } + if (disablesAllExport(run, index + 1)) { + return { kind: 'all-export-off', state }; + } } if (program === 'env') { const scan = consumeEnvWrapper(run, index, state); @@ -1745,6 +1787,35 @@ interface EvaluationScope { * quote and substitution rules. `separators[i]` follows segment `i`. Both * sides of a `|` run in subshells, so a `cd` there must not move the shell. */ +/** + * A heredoc body is stdin data delivered to the command, not shell commands, + * yet `splitCommands` has no heredoc state and would parse each body line as + * its own segment — letting a body `cd` launder the tracked directory. Strip + * `<<[-]WORD … WORD` bodies (quoted or not) before splitting. This is + * best-effort: only the first heredoc on a line is handled, which is the + * shape a model emits, and anything unrecognised is left untouched. + */ +function stripHeredocBodies(command: string): string { + const lines = command.split('\n'); + const out: string[] = []; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!; + out.push(line); + const match = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line); + if (!match) continue; + const delimiter = match[2]!; + const stripTabs = line.includes('<<-'); + // Consume the body up to the delimiter line, dropping it from the output. + while (index + 1 < lines.length) { + index++; + const body = lines[index]!; + const trimmed = stripTabs ? body.replace(/^\t+/, '') : body; + if (trimmed === delimiter) break; + } + } + return out.join('\n'); +} + function readTopLevelSeparators(command: string): string[] { const separators: string[] = []; let single = false; @@ -1796,6 +1867,9 @@ function readTopLevelSeparators(command: string): string[] { if (character === '&' && next === '&') { separators.push('&&'); index++; + } else if (character === '&' && next === '>') { + // `&>` / `&>>` redirects stdout+stderr; the `&` is not a separator. + index += command[index + 2] === '>' ? 2 : 1; } else if (character === '&') { // A lone `&` backgrounds the command in its own subshell. separators.push('&'); @@ -1850,7 +1924,8 @@ async function evaluateCommandWithCwd( // replayed verbatim, so the name is recorded as Git-shaped instead and the // later bare word answers to the unrecognized-program containment rule. const gitShapedNames = new Set(); - let insideDefinition = false; + let insideDefinition: string | undefined; + let definitionBody = ''; // Paths a run in this command may have re-pointed. Any containment the // guard proves for one of them afterwards is proved against the old target. // Shared with every nested evaluation, in both directions. @@ -1896,8 +1971,8 @@ async function evaluateCommandWithCwd( for (const [key, token] of snapshot.locals) shellLocals.set(key, token); }; const subshellCwds: ShellStateSnapshot[] = []; - const segments = splitCommands(command); - const separators = readTopLevelSeparators(command); + const segments = splitCommands(stripHeredocBodies(command)); + const separators = readTopLevelSeparators(stripHeredocBodies(command)); // On any disagreement with `splitCommands`, treat every segment of a piped // command as a pipeline component rather than guessing. const separatorsMatch = separators.length === segments.length - 1; @@ -1925,18 +2000,29 @@ async function evaluateCommandWithCwd( // `name() { … }` — shell-quote reports the parentheses as operators, so // the header is recognised on the raw segment. The body runs wherever the // name is later used, which is what the recorded shape stands in for. - const functionHeader = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)/.exec( - segment, - ); + const functionHeader = + /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)/.exec(segment) ?? + // The `function NAME` keyword form, with the `()` optional. + /^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(segment); if (functionHeader) { - if (GIT_WORD_PATTERN.test(segment)) { - gitShapedNames.add(functionHeader[1]!); + insideDefinition = functionHeader[1]!; + // Start the body at the first `{`; the header before it is not code. + const braceAt = segment.indexOf('{'); + definitionBody = braceAt >= 0 ? segment.slice(braceAt + 1) : ''; + } else if (insideDefinition !== undefined) { + definitionBody += `\n${segment}`; + } + if (insideDefinition !== undefined) { + if (segment.includes('}')) { + // Record the whole body so a later call replays it — a `-C ` + // or a `cd` inside it is then seen, not just the name. + const closeAt = definitionBody.lastIndexOf('}'); + const body = ( + closeAt >= 0 ? definitionBody.slice(0, closeAt) : definitionBody + ).trim(); + if (body.length > 0) definedBodies.set(insideDefinition, body); + insideDefinition = undefined; } - insideDefinition = true; - } - if (insideDefinition) { - // The remaining body segments are definition text, not execution. - if (segment.includes('}')) insideDefinition = false; continue; } @@ -2204,12 +2290,23 @@ async function evaluateCommandWithCwd( } case 'all-export': allExport = true; + // A leading `GIT_DIR=… set -a` still made that assignment; it is + // shell-local for now, promoted the moment allexport is on. + exported.relocations.push(...analysis.state.relocations); + if (analysis.state.unresolved) exported.unresolved = true; break; case 'all-export-off': allExport = false; break; case 'other': { - // `alias name=body` / `name() { body }` — record, don't execute. + // `alias name=body …` / `name() { body }` — record, don't execute. + const aliasDefinitions = readAliasDefinitions(run); + if (aliasDefinitions.length > 0) { + for (const definition of aliasDefinitions) { + definedBodies.set(definition.name, definition.body); + } + break; + } const definition = readDefinition(run); if (definition) { definedBodies.set(definition.name, definition.body); @@ -2263,9 +2360,12 @@ async function evaluateCommandWithCwd( run.some((t) => PATH_EXTRACTING_PROGRAMS.has(executableBaseName(t))) ) { // An archive can place a symlink anywhere below the extraction - // directory, so the directory itself is what became suspect. - if (trackedCwd === undefined) scope.relink.gitDir = true; - else relinkedTargets.push(trackedCwd); + // directory, so a later relocation resolving into it is suspect. + // A path-less run that merely discovers a repository from an + // extracted `.git` is a TOCTOU (the archive is unpacked after + // this decision) and is left to the same limitation as the + // symlink race rather than denying every `tar && git commit`. + if (trackedCwd !== undefined) relinkedTargets.push(trackedCwd); } if ( run.some((t) => PATH_RELINKING_PROGRAMS.has(executableBaseName(t))) From cf2ea23fe58ee6eb7423ddc92e867bbabd5c5f85 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 17:20:34 +0800 Subject: [PATCH 27/45] fix(daemon): replay an alias with the args its invocation appends `alias gg='git'; gg -C reset --hard` ran `git -C reset --hard`, but the guard replayed only the recorded body (`git`) and dropped the appended argv, so the relocation was invisible and the command was allowed. An alias now replays as `body + trailing args`, so the invocation's own `-C ` is seen. A function is unchanged: its args arrive through `$@` inside the body, which the recorded body already carries. Verified with in-boundary controls (`alias gg='git'; gg status`, `alias gg='git commit'; gg -m x`) staying allowed. --- .../serve/daemon-git-worktree-guard.test.ts | 26 ++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 34 +++++++++++++++---- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 1509095e6cc..1ff36defcb0 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1764,6 +1764,32 @@ it -C ${outsideRepo} reset --hard`, } }); + // An alias replaces its name with its body and keeps the trailing argv, so + // the relocation an invocation appends is part of what runs. + it.each([ + () => `alias gg='git'; gg -C ${plainOutsidePath} reset --hard`, + () => `alias gg='git -C'; gg ${plainOutsidePath} reset --hard`, + ])('denies a relocation passed to an alias %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves an alias used inside the boundary alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + "alias gg='git'; gg status", + "alias gg='git commit'; gg -m x", + "alias gg='git status'; cd nested; gg", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 8941c328d11..f2dad97d2e5 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1916,7 +1916,7 @@ async function evaluateCommandWithCwd( const shellLocals = scope.locals ?? new Map(); // `alias g='git …'` and `f() { git …; }` both make a later bare word run a // body defined earlier; without them that word is an opaque `other` run. - const definedBodies = new Map(); + const definedBodies = new Map(); // Names carrying the export attribute from a name-only `export KEY`; a // later assignment to one of them reaches the git subprocess. const exportedNames = scope.exportedNames ?? new Set(); @@ -2020,7 +2020,9 @@ async function evaluateCommandWithCwd( const body = ( closeAt >= 0 ? definitionBody.slice(0, closeAt) : definitionBody ).trim(); - if (body.length > 0) definedBodies.set(insideDefinition, body); + if (body.length > 0) { + definedBodies.set(insideDefinition, { body, alias: false }); + } insideDefinition = undefined; } continue; @@ -2303,13 +2305,19 @@ async function evaluateCommandWithCwd( const aliasDefinitions = readAliasDefinitions(run); if (aliasDefinitions.length > 0) { for (const definition of aliasDefinitions) { - definedBodies.set(definition.name, definition.body); + definedBodies.set(definition.name, { + body: definition.body, + alias: true, + }); } break; } const definition = readDefinition(run); if (definition) { - definedBodies.set(definition.name, definition.body); + definedBodies.set(definition.name, { + body: definition.body, + alias: false, + }); break; } const programToken = readProgramWord(run); @@ -2334,17 +2342,29 @@ async function evaluateCommandWithCwd( if (denial) return { denial, cwdAfter: trackedCwd }; break; } - const body = + const defined = programToken === undefined ? undefined : definedBodies.get(programToken); - if (body !== undefined) { + if (defined !== undefined) { if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; } + // An alias replaces its name with its body and keeps the trailing + // argv, so `gg -C reset --hard` runs `git -C + // reset --hard`. A function receives those args through `$@` + // inside its body, so nothing is appended here. + let replay = defined.body; + if (defined.alias) { + const programIndex = run.findIndex( + (token) => token.text === programToken, + ); + const args = joinArgvTexts(run.slice(programIndex + 1)); + if (args.length > 0) replay = `${replay} ${args}`; + } // The body runs here, at the cwd this word was reached with. const nested = await evaluateCommandWithCwd( - body, + replay, trackedCwd, entryCwd, activeContext(), From 7f4746f60ac1bb1afc7fa37c0e86ef554329d272 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 17:35:56 +0800 Subject: [PATCH 28/45] fix(daemon): carry a function/alias body's cwd and exports to the caller A shell function and an alias both run in the current shell, so a `cd` or an export inside the recorded body survives the call. The replay discarded `nested.cwdAfter` and the exported state, so `f() { cd ; }; f; git reset --hard` kept the old in-boundary tracked cwd and the path-free git mutation was judged inside while the real shell had moved outside. The nested cwd, exports and shell-locals now propagate back, exactly as an `eval` payload already does. Distinct from the earlier case where git appeared in the body itself. Verified: `f() { cd nested; }; f; git status` and `f() { echo hi; }; f; git commit` stay allowed. --- .../serve/daemon-git-worktree-guard.test.ts | 28 +++++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 14 +++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 1ff36defcb0..c771773ff29 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1790,6 +1790,34 @@ it -C ${outsideRepo} reset --hard`, } }); + // A function/alias runs in the current shell, so a `cd` or export in its + // body survives the call and a later path-free Git mutation is judged + // against where the body left the shell. + it.each([ + () => `f() { cd ${plainOutsidePath}; }; f; git reset --hard`, + () => + `f() { export GIT_DIR=${plainOutsidePath}/.git; }; f; git reset --hard`, + () => `alias gg='cd ${plainOutsidePath}'; gg; git reset --hard`, + ])('carries a body cwd/export out to the caller %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps an in-boundary body cwd shift allowed', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'f() { cd nested; }; f; git status', + 'f() { echo hi; }; f; git commit -m x', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index f2dad97d2e5..96b7ccf071c 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -2369,11 +2369,23 @@ async function evaluateCommandWithCwd( entryCwd, activeContext(), depth + 1, - { relink: scope.relink, locals: shellLocals }, + { relink: scope.relink, locals: shellLocals, exportedNames }, ); if (nested.denial) { return { denial: nested.denial, cwdAfter: trackedCwd }; } + // Both an alias and a function run in the current shell, so a `cd` + // or an export in the body survives the call — a later path-free + // git mutation is judged against where the body left the shell. + trackedCwd = nested.cwdAfter; + if (nested.exportedAfter) { + exported.relocations.push(...nested.exportedAfter.relocations); + if (nested.exportedAfter.unresolved) exported.unresolved = true; + } + if (nested.allExportAfter) allExport = true; + for (const [key, token] of nested.shellLocalsAfter ?? []) { + shellLocals.set(key, token); + } break; } if ( From 62734645ac6cef19f89c0480e89f9bdb3c876cb9 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 17:48:56 +0800 Subject: [PATCH 29/45] fix(daemon): inherit the caller's allexport into a same-shell body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A body run in the current shell — `eval`, an alias, or a function — inherits the enclosing `set -a`, so a plain `GIT_WORK_TREE=` assignment there is exported to the following git. The nested evaluation initialized `allExport` to false instead of the caller's value, so with allexport on the assignment was treated as shell-local, no relocation was recorded, and the path-free mutation was allowed. `allExport` now flows into the same-shell scopes (and back out). An unexported assignment stays shell-local and is still ignored. --- .../serve/daemon-git-worktree-guard.test.ts | 32 +++++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 15 +++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index c771773ff29..f46b30479da 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1818,6 +1818,38 @@ it -C ${outsideRepo} reset --hard`, } }); + // A body run in the current shell inherits the caller's `set -a`, so a + // plain assignment there is exported to the following git. + it.each([ + () => + `set -a; f() { GIT_WORK_TREE=${plainOutsidePath}; }; f; git reset --hard`, + () => `set -a; GIT_WORK_TREE=${plainOutsidePath}; git reset --hard`, + () => `set -a; eval 'GIT_WORK_TREE=${plainOutsidePath}'; git reset --hard`, + ])( + 'carries the caller allexport into a same-shell body %#', + async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('leaves an unexported body assignment alone', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // No `export`, no `set -a`: bash does not put it in git's environment. + for (const command of [ + `GIT_WORK_TREE=${plainOutsidePath}; git status`, + `f() { GIT_WORK_TREE=${plainOutsidePath}; }; f; git status`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 96b7ccf071c..81d37dfe011 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1780,6 +1780,10 @@ interface EvaluationScope { // Names carrying the export attribute, shared with `eval` for the same // reason its locals are. readonly exportedNames?: Set; + // `set -a` state from the enclosing shell — a body run in the current shell + // (`eval`, alias, function) inherits it, so a plain assignment there is + // exported just as the real shell would. + readonly allExport?: boolean; } /** @@ -1910,7 +1914,7 @@ async function evaluateCommandWithCwd( // Assignments this command exported into the environment of everything that // runs after them, and whether `set -a` made plain assignments exported. const exported: PrefixState = { relocations: [], unresolved: false }; - let allExport = false; + let allExport = scope.allExport ?? false; // GIT_* assignments made without `export`. They stay shell-local until a // name-only `export GIT_DIR` promotes them into the environment. const shellLocals = scope.locals ?? new Map(); @@ -2129,7 +2133,7 @@ async function evaluateCommandWithCwd( // and the export attributes; a `sh -c` subprocess inherits only // exported ones. ...(analysis.propagatesCwd - ? { locals: shellLocals, exportedNames } + ? { locals: shellLocals, exportedNames, allExport } : {}), }, ); @@ -2369,7 +2373,12 @@ async function evaluateCommandWithCwd( entryCwd, activeContext(), depth + 1, - { relink: scope.relink, locals: shellLocals, exportedNames }, + { + relink: scope.relink, + locals: shellLocals, + exportedNames, + allExport, + }, ); if (nested.denial) { return { denial: nested.denial, cwdAfter: trackedCwd }; From 01587abcfe8edd9ca7970359f9407d42312bd546 Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 19:48:11 +0800 Subject: [PATCH 30/45] fix(daemon): complete the same-shell state model for bodies and substitutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related gaps, all in the shell-state sharing this PR has been building: - A command substitution inherits the enclosing `set -a` but did not carry it in, so `set -a; echo $(GIT_WORK_TREE=; git reset --hard)` was allowed. The substitution scope now inherits allexport (by copy — its own changes still die with the subshell). - A same-shell body could turn allexport on but not off: the merge-back only handled the truthy result, so `set -a; f() { set +a; }; f; GIT_WORK_TREE=; git status` denied even though bash leaves the later assignment unexported. Both the function and eval merges now propagate the boolean in both directions. - Recorded function/alias definitions were local to each evaluator, so a body could not see a function the caller had already defined: `inner() { cd ; }; outer() { inner; }; outer; git reset --hard` ran `inner` as an opaque command and lost the cwd. The definition tables are now shared by reference with same-shell bodies (`eval`, function/alias replay) and copied for substitution subshells. --- .../serve/daemon-git-worktree-guard.test.ts | 33 +++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 35 +++++++++++++++---- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index f46b30479da..6c01eb03d5a 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1850,6 +1850,39 @@ it -C ${outsideRepo} reset --hard`, } }); + it.each([ + // A command substitution inherits the caller's `set -a`. + () => `set -a; echo $(GIT_WORK_TREE=${plainOutsidePath}; git reset --hard)`, + // A nested function defined in the caller is visible to the body it runs. + () => + `inner() { cd ${plainOutsidePath}; }; outer() { inner; }; outer; git reset --hard`, + ])( + 'shares option and definition state with a same-shell body %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('lets a same-shell body turn allexport back off', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // `set +a` in the body persists, so the later assignment is unexported. + for (const command of [ + `set -a; f() { set +a; }; f; GIT_WORK_TREE=${plainOutsidePath}; git status`, + `set -a; eval 'set +a'; GIT_WORK_TREE=${plainOutsidePath}; git status`, + // A substitution's own changes die with it. + `echo $(GIT_WORK_TREE=${plainOutsidePath}; git status)`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 81d37dfe011..ce92dad48d9 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1784,6 +1784,10 @@ interface EvaluationScope { // (`eval`, alias, function) inherits it, so a plain assignment there is // exported just as the real shell would. readonly allExport?: boolean; + // Alias/function bodies and their Git-shaped names, shared with a + // same-shell body so `outer() { inner; }` can see `inner`. + readonly definedBodies?: Map; + readonly gitShapedNames?: Set; } /** @@ -1920,14 +1924,15 @@ async function evaluateCommandWithCwd( const shellLocals = scope.locals ?? new Map(); // `alias g='git …'` and `f() { git …; }` both make a later bare word run a // body defined earlier; without them that word is an opaque `other` run. - const definedBodies = new Map(); + const definedBodies = + scope.definedBodies ?? new Map(); // Names carrying the export attribute from a name-only `export KEY`; a // later assignment to one of them reaches the git subprocess. const exportedNames = scope.exportedNames ?? new Set(); // Function bodies that `splitCommands` cut across segments cannot be // replayed verbatim, so the name is recorded as Git-shaped instead and the // later bare word answers to the unrecognized-program containment rule. - const gitShapedNames = new Set(); + const gitShapedNames = scope.gitShapedNames ?? new Set(); let insideDefinition: string | undefined; let definitionBody = ''; // Paths a run in this command may have re-pointed. Any containment the @@ -2044,12 +2049,16 @@ async function evaluateCommandWithCwd( entryCwd, activeContext(), depth + 1, - // A substitution runs in a subshell: it inherits the variables but - // its own assignments die with it, so it gets a copy. + // A substitution runs in a subshell: it inherits the variables, the + // option state and the definitions, but its own changes die with it, + // so it gets copies and nothing is merged back. { relink: scope.relink, locals: new Map(shellLocals), exportedNames: new Set(exportedNames), + allExport, + definedBodies: new Map(definedBodies), + gitShapedNames: new Set(gitShapedNames), }, ); if (nested.denial) { @@ -2133,7 +2142,13 @@ async function evaluateCommandWithCwd( // and the export attributes; a `sh -c` subprocess inherits only // exported ones. ...(analysis.propagatesCwd - ? { locals: shellLocals, exportedNames, allExport } + ? { + locals: shellLocals, + exportedNames, + allExport, + definedBodies, + gitShapedNames, + } : {}), }, ); @@ -2148,7 +2163,9 @@ async function evaluateCommandWithCwd( exported.relocations.push(...nested.exportedAfter.relocations); if (nested.exportedAfter.unresolved) exported.unresolved = true; } - if (nested.allExportAfter) allExport = true; + if (nested.allExportAfter !== undefined) { + allExport = nested.allExportAfter; + } for (const [key, token] of nested.shellLocalsAfter ?? []) { shellLocals.set(key, token); } @@ -2378,6 +2395,8 @@ async function evaluateCommandWithCwd( locals: shellLocals, exportedNames, allExport, + definedBodies, + gitShapedNames, }, ); if (nested.denial) { @@ -2391,7 +2410,9 @@ async function evaluateCommandWithCwd( exported.relocations.push(...nested.exportedAfter.relocations); if (nested.exportedAfter.unresolved) exported.unresolved = true; } - if (nested.allExportAfter) allExport = true; + if (nested.allExportAfter !== undefined) { + allExport = nested.allExportAfter; + } for (const [key, token] of nested.shellLocalsAfter ?? []) { shellLocals.set(key, token); } From 2f0b99fe4647d817e20fa5882292f43987540f2a Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 11 Aug 2026 20:29:27 +0800 Subject: [PATCH 31/45] fix(daemon): resolve shadowing and exported functions; isolate pipe subshells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four related function-model findings, all reproduced first: - A recorded function shadows the git program or a builtin, and bash resolves it before either — `git() { cd ; command git status; }; git` and `cd() { command cd ; }; cd nested; git reset --hard` were allowed because `analyzeRun` classified `git`/`cd` before the body lookup. Recorded bodies are now resolved before program/builtin dispatch, via a shared `invokeDefinedBody`; `command`/`builtin` name a different program word and bypass it as bash does. - A function/alias redefinition in a pipeline component runs in a subshell and must not persist, but sharing `definedBodies` (previous commit) let it leak: `f() { cd ; }; f() { :; } | cat; f` was modelled as a no-op. Pipe and background components no longer record a definition into the parent, and their cwd/allexport are already rolled back. - `export -f f` makes a function visible inside a `bash -c` subprocess, unlike an ordinary function. Those names are tracked and the subprocess payload is seeded with only the exported subset; an unexported function stays invisible to `bash -c`. --- .../serve/daemon-git-worktree-guard.test.ts | 35 ++++ .../src/serve/daemon-git-worktree-guard.ts | 156 +++++++++++------- 2 files changed, 135 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 6c01eb03d5a..7d090f2fc04 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1883,6 +1883,41 @@ it -C ${outsideRepo} reset --hard`, } }); + it.each([ + // A function/alias shadows the git program or a builtin; bash resolves it + // before either, so the recorded body must run first. + () => `git() { cd ${plainOutsidePath}; command git status; }; git`, + () => + `cd() { command cd ${plainOutsidePath}; }; cd nested; git reset --hard`, + // A pipeline redefinition runs in a subshell and does not persist. + () => + `f() { cd ${plainOutsidePath}; }; f() { :; } | cat; f; git reset --hard`, + // `export -f` makes a function visible inside a `bash -c` subprocess. + () => + `f() { cd ${plainOutsidePath}; }; export -f f; bash -c "f; git reset --hard"`, + ])('resolves a shadowing/exported function correctly %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('does not import an unexported function into a subprocess', async () => { + const guard = createDaemonToolGuard(); + + // Without `export -f`, `bash -c` does not see `f`, so this is an ordinary + // (path-free) git run inside the boundary. + await expect( + guard(request(`f() { cd ${plainOutsidePath}; }; bash -c 'git status'`)), + ).resolves.toEqual({ allowed: true }); + // `command git` explicitly bypasses a shadowing function. + await expect(guard(request('command git status'))).resolves.toEqual({ + allowed: true, + }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index ce92dad48d9..3ebbee21243 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1788,6 +1788,8 @@ interface EvaluationScope { // same-shell body so `outer() { inner; }` can see `inner`. readonly definedBodies?: Map; readonly gitShapedNames?: Set; + // Names carried by `export -f`, which a child shell (`bash -c`) imports. + readonly exportedFunctions?: Set; } /** @@ -1933,6 +1935,7 @@ async function evaluateCommandWithCwd( // replayed verbatim, so the name is recorded as Git-shaped instead and the // later bare word answers to the unrecognized-program containment rule. const gitShapedNames = scope.gitShapedNames ?? new Set(); + const exportedFunctions = scope.exportedFunctions ?? new Set(); let insideDefinition: string | undefined; let definitionBody = ''; // Paths a run in this command may have re-pointed. Any containment the @@ -1980,6 +1983,51 @@ async function evaluateCommandWithCwd( for (const [key, token] of snapshot.locals) shellLocals.set(key, token); }; const subshellCwds: ShellStateSnapshot[] = []; + // Replay a recorded alias/function body in the current shell, propagating + // its cwd and shell state back — an alias keeps the invocation's trailing + // argv, a function receives args through `$@`. + const invokeDefinedBody = async ( + programToken: string, + run: GuardToken[], + ): Promise => { + const defined = definedBodies.get(programToken)!; + if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) return denyDynamicRelocation(); + let replay = defined.body; + if (defined.alias) { + const programIndex = run.findIndex( + (token) => token.text === programToken, + ); + const args = joinArgvTexts(run.slice(programIndex + 1)); + if (args.length > 0) replay = `${replay} ${args}`; + } + const nested = await evaluateCommandWithCwd( + replay, + trackedCwd, + entryCwd, + activeContext(), + depth + 1, + { + relink: scope.relink, + locals: shellLocals, + exportedNames, + allExport, + definedBodies, + gitShapedNames, + }, + ); + if (nested.denial) return nested.denial; + trackedCwd = nested.cwdAfter; + if (nested.exportedAfter) { + exported.relocations.push(...nested.exportedAfter.relocations); + if (nested.exportedAfter.unresolved) exported.unresolved = true; + } + if (nested.allExportAfter !== undefined) allExport = nested.allExportAfter; + for (const [key, token] of nested.shellLocalsAfter ?? []) { + shellLocals.set(key, token); + } + return undefined; + }; + const segments = splitCommands(stripHeredocBodies(command)); const separators = readTopLevelSeparators(stripHeredocBodies(command)); // On any disagreement with `splitCommands`, treat every segment of a piped @@ -1996,6 +2044,13 @@ async function evaluateCommandWithCwd( for (const [segmentIndex, segment] of segments.entries()) { const pipeComponent = isPipeComponent(segmentIndex); const cwdBeforeSegment = trackedCwd; + const definedBodiesBefore = pipeComponent + ? new Map(definedBodies) + : undefined; + const gitShapedNamesBefore = pipeComponent + ? new Set(gitShapedNames) + : undefined; + const allExportBefore = allExport; const substitutions = extractCommandSubstitutions(segment); const tokenized = substitutions === null ? null : tokenizeSegment(segment, subshellDepth); @@ -2029,7 +2084,7 @@ async function evaluateCommandWithCwd( const body = ( closeAt >= 0 ? definitionBody.slice(0, closeAt) : definitionBody ).trim(); - if (body.length > 0) { + if (body.length > 0 && !pipeComponent) { definedBodies.set(insideDefinition, { body, alias: false }); } insideDefinition = undefined; @@ -2076,6 +2131,20 @@ async function evaluateCommandWithCwd( restoreShellState(subshellCwds.pop()); subshellDepth--; } + // A recorded function shadows a builtin or the git program, and bash + // resolves it before either. `command`/`builtin` name a different + // program word, so they bypass this naturally. + const invoked = readProgramWord(run); + if ( + invoked !== undefined && + definedBodies.has(invoked) && + readFunctionName(run) === undefined && + readAliasDefinitions(run).length === 0 + ) { + const denial = await invokeDefinedBody(invoked, run); + if (denial) return { denial, cwdAfter: trackedCwd }; + continue; + } const analysis = analyzeRun(run); switch (analysis.kind) { case 'cd': { @@ -2148,8 +2217,18 @@ async function evaluateCommandWithCwd( allExport, definedBodies, gitShapedNames, + exportedFunctions, } - : {}), + : { + // A `sh -c`/`bash -c` subprocess imports only functions + // carried by `export -f`. + definedBodies: new Map( + [...definedBodies].filter(([name]) => + exportedFunctions.has(name), + ), + ), + exportedFunctions, + }), }, ); if (nested.denial) { @@ -2279,6 +2358,13 @@ async function evaluateCommandWithCwd( case 'export': { exported.relocations.push(...analysis.state.relocations); if (analysis.state.unresolved) exported.unresolved = true; + if (analysis.operands.some((op) => op.text === '-f')) { + for (const op of analysis.operands) { + if (!op.text.startsWith('-') && !op.dynamic) { + exportedFunctions.add(op.text); + } + } + } // `export GIT_DIR` with no `=` exports whatever an earlier // shell-local assignment left in that name — and, because the // export *attribute* sticks to the name, whatever a later one puts @@ -2363,59 +2449,9 @@ async function evaluateCommandWithCwd( if (denial) return { denial, cwdAfter: trackedCwd }; break; } - const defined = - programToken === undefined - ? undefined - : definedBodies.get(programToken); - if (defined !== undefined) { - if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) { - return { denial: denyDynamicRelocation(), cwdAfter: trackedCwd }; - } - // An alias replaces its name with its body and keeps the trailing - // argv, so `gg -C reset --hard` runs `git -C - // reset --hard`. A function receives those args through `$@` - // inside its body, so nothing is appended here. - let replay = defined.body; - if (defined.alias) { - const programIndex = run.findIndex( - (token) => token.text === programToken, - ); - const args = joinArgvTexts(run.slice(programIndex + 1)); - if (args.length > 0) replay = `${replay} ${args}`; - } - // The body runs here, at the cwd this word was reached with. - const nested = await evaluateCommandWithCwd( - replay, - trackedCwd, - entryCwd, - activeContext(), - depth + 1, - { - relink: scope.relink, - locals: shellLocals, - exportedNames, - allExport, - definedBodies, - gitShapedNames, - }, - ); - if (nested.denial) { - return { denial: nested.denial, cwdAfter: trackedCwd }; - } - // Both an alias and a function run in the current shell, so a `cd` - // or an export in the body survives the call — a later path-free - // git mutation is judged against where the body left the shell. - trackedCwd = nested.cwdAfter; - if (nested.exportedAfter) { - exported.relocations.push(...nested.exportedAfter.relocations); - if (nested.exportedAfter.unresolved) exported.unresolved = true; - } - if (nested.allExportAfter !== undefined) { - allExport = nested.allExportAfter; - } - for (const [key, token] of nested.shellLocalsAfter ?? []) { - shellLocals.set(key, token); - } + if (programToken !== undefined && definedBodies.has(programToken)) { + const denial = await invokeDefinedBody(programToken, run); + if (denial) return { denial, cwdAfter: trackedCwd }; break; } if ( @@ -2509,7 +2545,15 @@ async function evaluateCommandWithCwd( } // Both sides of a pipe run in their own subshell, so whatever this // segment did to the shell's directory dies with it. - if (pipeComponent) trackedCwd = cwdBeforeSegment; + if (pipeComponent) { + // A subshell's cwd, option state and definitions die with it. + trackedCwd = cwdBeforeSegment; + allExport = allExportBefore; + definedBodies.clear(); + for (const [k, v] of definedBodiesBefore!) definedBodies.set(k, v); + gitShapedNames.clear(); + for (const k of gitShapedNamesBefore!) gitShapedNames.add(k); + } } return { cwdAfter: trackedCwd, From a2ae8bbccc3874dd3c18b9977c7c93e5af2933b2 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 02:00:37 +0800 Subject: [PATCH 32/45] fix(daemon): close the interlocking gaps in my function-model work Four gaps in the recorded-body machinery the last commits built, all reproduced first: - `invokeDefinedBody` did not carry `exportedFunctions` into the replayed body, so a `export -f`'d function invoked from another function's body was invisible to its `bash -c`. - A prefix assignment on the invocation (`GIT_WORK_TREE= gg`) was dropped, because the defined-body gate skips `analyzeRun`; the run's leading assignments are now applied to the body as ambient relocations. - The pipe-component rollback restored cwd/allexport/definitions but leaked the subshell's exports, export attributes and shell-locals into the parent; all of them now roll back. --- .../serve/daemon-git-worktree-guard.test.ts | 31 +++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 52 +++++++++++++++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 7d090f2fc04..16cb8621234 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1918,6 +1918,37 @@ it -C ${outsideRepo} reset --hard`, }); }); + // Gaps in the function-model work of the preceding commits. + it.each([ + // `export -f` state must reach a nested same-shell body too. + () => + `f() { cd ${plainOutsidePath}; }; export -f f; g() { bash -c "f; git reset --hard"; }; g`, + // A prefix assignment on a function/alias invocation reaches its git. + () => `gg() { git status; }; GIT_WORK_TREE=${plainOutsidePath} gg`, + () => `alias gg='git status'; GIT_WORK_TREE=${plainOutsidePath} gg`, + ])('propagates invocation state into a recorded body %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('rolls back a pipe subshell fully', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + // The pipe-side export/assignment dies with the subshell. + await expect( + guard( + request( + `export GIT_WORK_TREE; GIT_WORK_TREE=${plainOutsidePath} | cat; git status`, + ), + ), + ).resolves.toEqual({ allowed: true }); + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 3ebbee21243..0c12151dac0 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1993,18 +1993,36 @@ async function evaluateCommandWithCwd( const defined = definedBodies.get(programToken)!; if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) return denyDynamicRelocation(); let replay = defined.body; + const programIndex = run.findIndex((token) => token.text === programToken); if (defined.alias) { - const programIndex = run.findIndex( - (token) => token.text === programToken, - ); const args = joinArgvTexts(run.slice(programIndex + 1)); if (args.length > 0) replay = `${replay} ${args}`; } + // `VAR=val name` puts the assignment in the call's environment, so the + // body's git sees it — record the leading assignments as ambient. + const prefix: PrefixState = { relocations: [], unresolved: false }; + for (const token of run.slice(0, programIndex)) { + if (leadingEnvAssignmentKey(token.text) !== null) { + recordEnvAssignment(token, prefix); + } + } + const base = activeContext(); + const bodyContext: GuardEvaluationContext = + prefix.relocations.length > 0 || prefix.unresolved + ? { + canonicalEffectiveCwd: base.canonicalEffectiveCwd, + ambientRelocations: [ + ...base.ambientRelocations, + ...prefix.relocations, + ], + ambientUnresolved: base.ambientUnresolved || prefix.unresolved, + } + : base; const nested = await evaluateCommandWithCwd( replay, trackedCwd, entryCwd, - activeContext(), + bodyContext, depth + 1, { relink: scope.relink, @@ -2013,6 +2031,7 @@ async function evaluateCommandWithCwd( allExport, definedBodies, gitShapedNames, + exportedFunctions, }, ); if (nested.denial) return nested.denial; @@ -2050,6 +2069,19 @@ async function evaluateCommandWithCwd( const gitShapedNamesBefore = pipeComponent ? new Set(gitShapedNames) : undefined; + const exportedNamesBefore = pipeComponent + ? new Set(exportedNames) + : undefined; + const exportedFunctionsBefore = pipeComponent + ? new Set(exportedFunctions) + : undefined; + const shellLocalsBefore = pipeComponent ? new Map(shellLocals) : undefined; + const exportedBefore = pipeComponent + ? { + relocations: [...exported.relocations], + unresolved: exported.unresolved, + } + : undefined; const allExportBefore = allExport; const substitutions = extractCommandSubstitutions(segment); const tokenized = @@ -2546,13 +2578,23 @@ async function evaluateCommandWithCwd( // Both sides of a pipe run in their own subshell, so whatever this // segment did to the shell's directory dies with it. if (pipeComponent) { - // A subshell's cwd, option state and definitions die with it. + // A subshell keeps nothing: its cwd, option state, definitions, + // exports and variables all die with it. trackedCwd = cwdBeforeSegment; allExport = allExportBefore; definedBodies.clear(); for (const [k, v] of definedBodiesBefore!) definedBodies.set(k, v); gitShapedNames.clear(); for (const k of gitShapedNamesBefore!) gitShapedNames.add(k); + exportedNames.clear(); + for (const k of exportedNamesBefore!) exportedNames.add(k); + exportedFunctions.clear(); + for (const k of exportedFunctionsBefore!) exportedFunctions.add(k); + shellLocals.clear(); + for (const [k, v] of shellLocalsBefore!) shellLocals.set(k, v); + exported.relocations.length = 0; + exported.relocations.push(...exportedBefore!.relocations); + exported.unresolved = exportedBefore!.unresolved; } } return { From 6deb8bd5a38f15a2f70aa37cc1aef153371593f4 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 02:07:36 +0800 Subject: [PATCH 33/45] fix(daemon): deny relocations disguised by a redirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reachable escapes with ordinary (non-adversarial) commands: - `cd >&2; git reset --hard` — a stderr redirect on the `cd`, whose `&` was read as a background separator so the tracked cwd was rewound while the real shell had moved outside. `>&`/`<&` file-descriptor redirects are no longer treated as backgrounding. - `git 2>/dev/null -C reset --hard` — the redirect operand among the git args ended `readGitInvocation`'s option parsing before the `-C`, so the relocation was invisible. It now skips redirect/fd-flagged tokens. Ordinary trailing redirects (`git status 2>/dev/null`) stay allowed. --- .../serve/daemon-git-worktree-guard.test.ts | 26 +++++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 11 ++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 16cb8621234..d78519541c8 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1949,6 +1949,32 @@ it -C ${outsideRepo} reset --hard`, ).resolves.toEqual({ allowed: true }); }); + // Reachable escapes via a redirection on a `cd` or inside a git run. + it.each([ + () => `cd ${plainOutsidePath} >&2; git reset --hard`, + () => `git 2>/dev/null -C ${plainOutsidePath} reset --hard`, + () => `git -C ${plainOutsidePath} 2>/dev/null reset --hard`, + ])('denies a relocation around a redirection %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('leaves an ordinary redirection alone', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + 'cd nested >&2; git status', + 'git status 2>/dev/null', + 'git -C nested reset --hard 2>&1', + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 0c12151dac0..d0d0d7fa8ae 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -1248,6 +1248,12 @@ function readGitInvocation(tokens: GuardToken[]): GitInvocation { let index = 1; while (index < tokens.length) { const token = tokens[index]!; + if (token.redirect || token.ambiguousFd) { + // A redirection operand among the args (`git 2>/dev/null -C

…`) is + // not part of argv and must not terminate option parsing. + index++; + continue; + } if (token.dynamic || BRACE_EXPANSION_PATTERN.test(token.text)) { unresolved = true; index++; @@ -1880,6 +1886,11 @@ function readTopLevelSeparators(command: string): string[] { } else if (character === '&' && next === '>') { // `&>` / `&>>` redirects stdout+stderr; the `&` is not a separator. index += command[index + 2] === '>' ? 2 : 1; + } else if ( + character === '&' && + (command[index - 1] === '>' || command[index - 1] === '<') + ) { + // `>&2` / `<&fd` — the `&` is part of a file-descriptor redirect. } else if (character === '&') { // A lone `&` backgrounds the command in its own subshell. separators.push('&'); From a43faa8fe999dffed5a867aecd371aa521189ce1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 08:57:04 +0800 Subject: [PATCH 34/45] fix(daemon): deny relocation hidden by a leading redirect or a background & MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more reachable escapes with ordinary commands, triaged out of the R8 batch (the rest of which is Windows paths, docs wording, test coverage or adversarial parser edges under the documented best-effort promise): - `2>/dev/null gg` where `gg` is a recorded alias/function ran the body in bash, but `readProgramWord` returned the fd token instead of the program word, so the invocation was not resolved. It now skips redirect/fd operands. - `true & cd ; git reset --hard` — only the segment a `&` follows is backgrounded (a subshell); the segment after it runs in the foreground, so its `cd` persists. The pipe-component test now treats a segment as a subshell only when it precedes `&`, while both sides of a `|` still are. --- .../serve/daemon-git-worktree-guard.test.ts | 28 +++++++++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 12 +++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index d78519541c8..44826832ac5 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -1975,6 +1975,34 @@ it -C ${outsideRepo} reset --hard`, } }); + it.each([ + // A redirection before an alias/function invocation must not hide it. + () => `alias gg='git -C ${plainOutsidePath} reset --hard'; 2>/dev/null gg`, + () => `gg() { git -C ${plainOutsidePath} reset --hard; }; 2>/dev/null gg`, + // Only the segment `&` follows is backgrounded; the next runs foreground. + () => `true & cd ${plainOutsidePath}; git reset --hard`, + ])('denies a relocation past a redirect or background %#', async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }); + + it('keeps foreground/background boundaries correct', async () => { + const guard = createDaemonToolGuard(); + + for (const command of [ + // The backgrounded `cd` is a subshell; the foreground git stays inside. + `cd ${outsideRepo} & git status`, + 'true & cd nested; git status', + "2>/dev/null alias gg='git status'; gg", + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index d0d0d7fa8ae..144af81b833 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -997,6 +997,7 @@ function disablesAllExport(run: GuardToken[], start: number): boolean { */ function readProgramWord(run: GuardToken[]): string | undefined { for (const token of run) { + if (token.redirect || token.ambiguousFd) continue; if (leadingEnvAssignmentKey(token.text) !== null) continue; if (LEADING_SHELL_KEYWORDS.has(token.text)) continue; return token.text; @@ -2063,14 +2064,17 @@ async function evaluateCommandWithCwd( // On any disagreement with `splitCommands`, treat every segment of a piped // command as a pipeline component rather than guessing. const separatorsMatch = separators.length === segments.length - 1; - const SUBSHELL_SEPARATORS = new Set(['|', '&']); const isPipeComponent = (index: number): boolean => separatorsMatch - ? SUBSHELL_SEPARATORS.has(separators[index - 1] ?? '') || - SUBSHELL_SEPARATORS.has(separators[index] ?? '') + ? // Both sides of a pipe run in subshells; for `&` only the segment it + // follows (the backgrounded one) does — the next segment is + // foreground. + separators[index - 1] === '|' || + separators[index] === '|' || + separators[index] === '&' : // Structural disagreement with `splitCommands`: scope every segment // rather than guess which ones ran in a subshell. - separators.some((separator) => SUBSHELL_SEPARATORS.has(separator)); + separators.some((separator) => separator === '|' || separator === '&'); for (const [segmentIndex, segment] of segments.entries()) { const pipeComponent = isPipeComponent(segmentIndex); const cwdBeforeSegment = trackedCwd; From e595f7b613c454a3d821744e1e25da4c93c09741 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 10:50:56 +0800 Subject: [PATCH 35/45] fix(daemon): don't let a harmless or removed shadow mask a relocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two escapes where the guard replayed a recorded body while the real interpreter ran a relocating external git, both reproduced first: - `export -f` functions were seeded into every subprocess shell, but only bash imports them. `git() { :; }; export -f git; dash -c "git -C reset --hard"` was allowed because the guard replayed the harmless `:` for dash, while real dash resolves the external git and relocates. Exported functions are now seeded only for a bash child. - `definedBodies`/`gitShapedNames`/`exportedFunctions` only ever gained entries, so a removed shadow still replayed. `unset -f`/`unalias` now drop the function/alias (and `-a` clears all), and `export -n -f` clears the export attribute — `git() { :; }; unset -f git; git -C reset --hard` and the `unalias git` form now deny, while a live compatible shadow (bash-imported function, an alias still in effect) stays modelled. --- .../serve/daemon-git-worktree-guard.test.ts | 38 ++++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 60 ++++++++++++++++--- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 44826832ac5..2d393c794d1 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2003,6 +2003,44 @@ it -C ${outsideRepo} reset --hard`, } }); + // A harmless recorded body must not mask a relocation the real interpreter + // would run: only bash imports `export -f`, and removals retract a shadow. + it.each([ + // dash does not import the exported function, so the real git relocates. + () => + `git() { :; }; export -f git; dash -c "git -C ${plainOutsidePath} reset --hard"`, + // `unset -f`/`unalias` remove the shadow, exposing the real git. + () => `git() { :; }; unset -f git; git -C ${plainOutsidePath} reset --hard`, + () => + `alias git='echo hi'; unalias git; git -C ${plainOutsidePath} reset --hard`, + ])( + 'does not let a stale/incompatible shadow mask a relocation %#', + async (b) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(b()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + + it('keeps a live compatible shadow modelled', async () => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + for (const command of [ + // bash imports the function, which really shadows git and drops args. + `git() { :; }; export -f git; bash -c "git -C ${plainOutsidePath} reset --hard"`, + // The alias is still in effect (no removal). + `alias git='echo hi'; git -C ${plainOutsidePath} reset --hard`, + // Removing a different name leaves the git shadow intact. + `git() { :; }; unset -f other; git -C ${plainOutsidePath} reset --hard`, + ]) { + await expect(guard(request(command))).resolves.toEqual({ allowed: true }); + } + }); + // The shell-executing set pins ToolNames literals in acp-bridge, which // cannot import core; a rename must fail here. it('matches the ToolNames constants for shell-executing tools', () => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 144af81b833..b8dfc3f41ab 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -879,6 +879,9 @@ type RunAnalysis = payload: string; state: PrefixState; propagatesCwd: boolean; + // Only bash imports functions marked with `export -f`; dash/sh/zsh/ksh + // resolve the external program instead. + importsExportedFunctions?: boolean; } | { kind: 'cd'; @@ -1165,6 +1168,7 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { payload: scan.payload, state, propagatesCwd: false, + importsExportedFunctions: program === 'bash', }; } if (program === 'nohup' || program === 'exec') { @@ -2178,6 +2182,43 @@ async function evaluateCommandWithCwd( restoreShellState(subshellCwds.pop()); subshellDepth--; } + // Removal builtins retract earlier definitions: `unset -f`/`unalias` + // drop a function/alias, `export -n -f` clears the export attribute. + const removalProgram = readProgramWord(run); + if (removalProgram === 'unset' || removalProgram === 'unalias') { + const isFunctions = + removalProgram === 'unalias' || + run.some((token) => token.text === '-f'); + const clearAll = run.some( + (token) => token.text === '-a' || token.text === '-af', + ); + if (clearAll) { + definedBodies.clear(); + gitShapedNames.clear(); + } else { + for (const token of run.slice(1)) { + if (token.text.startsWith('-')) continue; + if (isFunctions || removalProgram === 'unalias') { + definedBodies.delete(token.text); + gitShapedNames.delete(token.text); + exportedFunctions.delete(token.text); + } + } + } + continue; + } + if ( + removalProgram === 'export' && + run.some((token) => token.text === '-n') && + run.some((token) => token.text === '-f') + ) { + for (const token of run.slice(1)) { + if (!token.text.startsWith('-') && !token.dynamic) { + exportedFunctions.delete(token.text); + } + } + continue; + } // A recorded function shadows a builtin or the git program, and bash // resolves it before either. `command`/`builtin` name a different // program word, so they bypass this naturally. @@ -2266,16 +2307,17 @@ async function evaluateCommandWithCwd( gitShapedNames, exportedFunctions, } - : { - // A `sh -c`/`bash -c` subprocess imports only functions - // carried by `export -f`. - definedBodies: new Map( - [...definedBodies].filter(([name]) => - exportedFunctions.has(name), + : analysis.importsExportedFunctions + ? { + // Only bash imports `export -f` functions. + definedBodies: new Map( + [...definedBodies].filter(([name]) => + exportedFunctions.has(name), + ), ), - ), - exportedFunctions, - }), + exportedFunctions, + } + : {}), }, ); if (nested.denial) { From 70f5162371e0d8fd6006e13be207f323e7b46ef8 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 11:12:36 +0800 Subject: [PATCH 36/45] fix(daemon): drop exported functions when env clears the child environment Two follow-ups to the per-interpreter shadow modelling: - The `unalias`/`unset -f` removal branch compared `removalProgram` against `'unalias'` after `isFunctions` had already narrowed it to `'unset'`, which `tsc --build` rejects as a no-overlap comparison (TS2367). `isFunctions` already covers every `unalias` case, so drop the redundant term. - `env -i` / `-` / `--ignore-environment` start the child from an empty environment, so a bash `-c` payload no longer inherits the parent's `export -f` functions. The env wrapper now records that the environment was cleared and the bash payload stops importing exported functions when it was, so `git() { :; }; export -f git; env -i bash -c "git -C reset --hard"` denies while `env -i bash -c "... rev-parse HEAD"` and an un-cleared `env FOO=bar bash -c` stay allowed. Regressions added. --- .../serve/daemon-git-worktree-guard.test.ts | 12 +++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 20 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 2d393c794d1..e3443024112 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2013,6 +2013,14 @@ it -C ${outsideRepo} reset --hard`, () => `git() { :; }; unset -f git; git -C ${plainOutsidePath} reset --hard`, () => `alias git='echo hi'; unalias git; git -C ${plainOutsidePath} reset --hard`, + // `env -i`/`-`/`--ignore-environment` wipe the exported function before + // bash starts, so even bash resolves the real git. + () => + `git() { :; }; export -f git; env -i bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env - bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env --ignore-environment bash -c "git -C ${plainOutsidePath} reset --hard"`, ])( 'does not let a stale/incompatible shadow mask a relocation %#', async (b) => { @@ -2036,6 +2044,10 @@ it -C ${outsideRepo} reset --hard`, `alias git='echo hi'; git -C ${plainOutsidePath} reset --hard`, // Removing a different name leaves the git shadow intact. `git() { :; }; unset -f other; git -C ${plainOutsidePath} reset --hard`, + // `env -i` clears the function, but a read-only relocation is still fine. + `git() { :; }; export -f git; env -i bash -c "git -C ${plainOutsidePath} rev-parse HEAD"`, + // `env` without a clearing flag keeps the bash-imported shadow live. + `git() { :; }; export -f git; env FOO=bar bash -c "git -C ${plainOutsidePath} reset --hard"`, ]) { await expect(guard(request(command))).resolves.toEqual({ allowed: true }); } diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index b8dfc3f41ab..3456a2f6514 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -163,6 +163,11 @@ const ENV_KNOWN_FLAG_ONLY = new Set([ '--debug', ]); +// The subset of the flag-only options that start the child from an empty +// environment. `-` is GNU env's shorthand for `-i`. Bundled forms (`-iv`) are +// not exact members and already fail closed as unrecognized options. +const ENV_CLEARS_ENVIRONMENT = new Set(['-', '-i', '--ignore-environment']); + // Union of core shell-utils/shell.ts value-taking sudo options. const SUDO_VALUE_FLAGS = new Set([ '-C', @@ -274,6 +279,9 @@ interface GitEnvRelocation { interface PrefixState { readonly relocations: GitEnvRelocation[]; unresolved: boolean; + // `env -i` / `--ignore-environment` wipe the inherited environment, so a + // later shell child receives none of the parent's `export -f` functions. + clearsEnvironment?: boolean; } type GuardDenial = { allowed: false; reason: string }; @@ -647,6 +655,9 @@ function consumeEnvWrapper( break; } if (ENV_KNOWN_FLAG_ONLY.has(token.text)) { + if (ENV_CLEARS_ENVIRONMENT.has(token.text)) { + state.clearsEnvironment = true; + } index++; continue; } @@ -1168,7 +1179,10 @@ function analyzeRun(run: GuardToken[]): RunAnalysis { payload: scan.payload, state, propagatesCwd: false, - importsExportedFunctions: program === 'bash', + // Only bash imports `export -f` functions, and only when it inherits + // the environment carrying them — `env -i bash -c` wipes them first. + importsExportedFunctions: + program === 'bash' && !state.clearsEnvironment, }; } if (program === 'nohup' || program === 'exec') { @@ -2198,7 +2212,9 @@ async function evaluateCommandWithCwd( } else { for (const token of run.slice(1)) { if (token.text.startsWith('-')) continue; - if (isFunctions || removalProgram === 'unalias') { + // `unalias` always removes an alias; `unset` removes a function + // only with `-f` — both captured by `isFunctions`. + if (isFunctions) { definedBodies.delete(token.text); gitShapedNames.delete(token.text); exportedFunctions.delete(token.text); From 68eb5343958b71a6151290b89b3cf45515f5e87c Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 11:41:10 +0800 Subject: [PATCH 37/45] test(daemon): pin the sh-wrapper fail-closed contract and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sh` is bash on macOS and dash elsewhere, so its `export -f` import behaviour cannot be decided from the basename. The guard already treats `sh` as non-importing — it never replays an exported shadow for `sh -c`, because doing so on a dash-backed `sh` would recreate the relocation escape. Pin that fail-closed contract with a regression (`export -f git; sh -c "git -C reset --hard"` denies) so a future change that widens the bash gate to include `sh` breaks a test, and record the deliberate over-denial in the design doc's non-goals. --- docs/design/daemon-git-worktree-guard.md | 7 +++++++ packages/cli/src/serve/daemon-git-worktree-guard.test.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/docs/design/daemon-git-worktree-guard.md b/docs/design/daemon-git-worktree-guard.md index 2a4c41c49dc..8dd65e97303 100644 --- a/docs/design/daemon-git-worktree-guard.md +++ b/docs/design/daemon-git-worktree-guard.md @@ -250,6 +250,13 @@ own design; this one should not grow into it by accretion. - No general shell interpreter or environment-variable analysis: script files run by `bash script.sh` or `source` are not read, and variable values are not tracked across commands. +- No resolution of the `sh` implementation: only `bash` imports `export -f` + functions, but `sh` is bash on macOS and dash elsewhere. The basename cannot + say which, so the guard never replays an exported shadow for `sh -c` — + importing it on a dash-backed `sh` would recreate the escape. It fails + closed, over-denying the bash-backed case (a false positive, not a bypass). + `env -i`/`-`/`--ignore-environment` likewise drop the exported functions + before a bash child starts, so they are not imported into that payload. - No revocation of a recorded relocation: `unset GIT_DIR` and `env -u GIT_DIR` later in the same chain do not clear an exported GIT\_\* relocation, so such a chain can be denied even though the real shell would run it inside the diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index e3443024112..a5e2f01d867 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2013,6 +2013,12 @@ it -C ${outsideRepo} reset --hard`, () => `git() { :; }; unset -f git; git -C ${plainOutsidePath} reset --hard`, () => `alias git='echo hi'; unalias git; git -C ${plainOutsidePath} reset --hard`, + // `sh` resolves to dash on most daemons but to bash on macOS, so the + // guard never replays an exported shadow for it: importing on a + // dash-backed `sh` would recreate the escape. It fails closed — the + // deliberate, safe trade-off is over-denying the bash-backed case. + () => + `git() { :; }; export -f git; sh -c "git -C ${plainOutsidePath} reset --hard"`, // `env -i`/`-`/`--ignore-environment` wipe the exported function before // bash starts, so even bash resolves the real git. () => From 33333318c65dbe85453ecd51d8a4493f3b3f6b5b Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 18:50:23 +0800 Subject: [PATCH 38/45] fix(daemon): model shell-definition removal the way the real shell does The removal-builtin handling added earlier was too broad and dropped live relocating shadows, and the exported-function set was shared into subprocess scopes by reference. Each escape below was reproduced against the guard first. - `unset` has no `-a` option and `unalias -a` clears only aliases, yet both were treated as "clear every definition", so `pwn(){ git -C reset --hard; }; unset -a; pwn` (and the `unalias -a` form) wiped the function and ran it unrecognized. Removal is now kind-aware: `unalias` touches only aliases, `unset -f`/bare `unset` only functions. - A function shadowing `unset`/`unalias`/`export` runs instead of the builtin, so the removal never happens; the branch now fires only when the name is not itself a recorded shadow, and otherwise falls through to replay the shadow. - The bash `-c` subprocess and command-substitution scopes received the parent's `exportedFunctions` set by reference (or, for `$( )`, not at all), so a child `unset -f` retracted the parent's export and a substitution saw none. Both now take a copy. Adds regressions for each and keeps the existing shadow/removal cases green. --- .../serve/daemon-git-worktree-guard.test.ts | 30 ++++++++++ .../src/serve/daemon-git-worktree-guard.ts | 56 +++++++++++++------ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index a5e2f01d867..7670d5bfc97 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2039,6 +2039,36 @@ it -C ${outsideRepo} reset --hard`, }, ); + // Removal builtins must retract a shadow only the way the real shell does: + // mis-modelling one drops a live relocating function and allows the command. + it.each([ + // `unset` has no `-a` option: the command errors, the function survives. + () => `pwn() { git -C ${plainOutsidePath} reset --hard; }; unset -a; pwn`, + // `unalias -a` clears aliases, never functions. + () => `pwn() { git -C ${plainOutsidePath} reset --hard; }; unalias -a; pwn`, + // A function shadowing `unset` runs `:` instead of the builtin, so the + // removal never happens and the shadow stays live. + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; unset() { :; }; unset -f pwn; pwn`, + // A `-c` subprocess is a separate process: its `unset -f` cannot retract + // the parent's exported function. + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; export -f pwn; bash -c "unset -f pwn"; bash -c pwn`, + // A command substitution inherits the exported function too. + () => + `evil() { git -C ${plainOutsidePath} reset --hard; }; export -f evil; echo $(bash -c 'evil')`, + ])( + 'does not let a mis-modelled removal drop a live relocating shadow %#', + async (build) => { + await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); + const guard = createDaemonToolGuard(); + + await expect(guard(request(build()))).resolves.toMatchObject({ + allowed: false, + }); + }, + ); + it('keeps a live compatible shadow modelled', async () => { await mkdir(path.join(plainOutsidePath, '.git'), { recursive: true }); const guard = createDaemonToolGuard(); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 3456a2f6514..0c33c98e914 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -2179,6 +2179,9 @@ async function evaluateCommandWithCwd( allExport, definedBodies: new Map(definedBodies), gitShapedNames: new Set(gitShapedNames), + // A subshell inherits `export -f` functions too; copy so its own + // definitions and removals die with it. + exportedFunctions: new Set(exportedFunctions), }, ); if (nested.denial) { @@ -2196,25 +2199,39 @@ async function evaluateCommandWithCwd( restoreShellState(subshellCwds.pop()); subshellDepth--; } - // Removal builtins retract earlier definitions: `unset -f`/`unalias` - // drop a function/alias, `export -n -f` clears the export attribute. + // Removal builtins retract earlier definitions, but only the real + // builtin does: a function shadowing `unset`/`unalias`/`export` runs + // instead, so those fall through to the shadow dispatch below and remove + // nothing. `unalias` touches only aliases; `unset -f` (or a bare + // `unset NAME`, which falls back to the function when no variable + // shadows it) touches only functions; `unset` has no `-a` option, so it + // never clears wholesale — mis-modelling any of these would drop a live + // relocating shadow and allow the command. const removalProgram = readProgramWord(run); - if (removalProgram === 'unset' || removalProgram === 'unalias') { - const isFunctions = - removalProgram === 'unalias' || - run.some((token) => token.text === '-f'); - const clearAll = run.some( - (token) => token.text === '-a' || token.text === '-af', - ); - if (clearAll) { - definedBodies.clear(); - gitShapedNames.clear(); - } else { + if ( + (removalProgram === 'unset' || removalProgram === 'unalias') && + !definedBodies.has(removalProgram) + ) { + const removesAliases = removalProgram === 'unalias'; + const removesVariablesOnly = + !removesAliases && run.some((token) => token.text === '-v'); + if ( + removesAliases && + run.some((token) => token.text === '-a' || token.text === '-af') + ) { + // `unalias -a` clears every alias but leaves functions intact. + for (const [name, entry] of [...definedBodies]) { + if (entry.alias) definedBodies.delete(name); + } + } else if (!removesVariablesOnly) { for (const token of run.slice(1)) { if (token.text.startsWith('-')) continue; - // `unalias` always removes an alias; `unset` removes a function - // only with `-f` — both captured by `isFunctions`. - if (isFunctions) { + const entry = definedBodies.get(token.text); + if (removesAliases) { + if (entry?.alias) definedBodies.delete(token.text); + } else if (!entry?.alias) { + // `unset -f NAME` / bare `unset NAME`: a function and its + // git/export attributes, never an alias. definedBodies.delete(token.text); gitShapedNames.delete(token.text); exportedFunctions.delete(token.text); @@ -2225,6 +2242,7 @@ async function evaluateCommandWithCwd( } if ( removalProgram === 'export' && + !definedBodies.has('export') && run.some((token) => token.text === '-n') && run.some((token) => token.text === '-f') ) { @@ -2325,13 +2343,15 @@ async function evaluateCommandWithCwd( } : analysis.importsExportedFunctions ? { - // Only bash imports `export -f` functions. + // Only bash imports `export -f` functions. A `-c` + // subprocess is a separate process: copy the set so a + // child `unset -f` cannot retract the parent's exports. definedBodies: new Map( [...definedBodies].filter(([name]) => exportedFunctions.has(name), ), ), - exportedFunctions, + exportedFunctions: new Set(exportedFunctions), } : {}), }, From 636602ce7016f4c253be96f687361f8cc82333a5 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 12 Aug 2026 20:58:14 +0800 Subject: [PATCH 39/45] fix(daemon): bare unset keeps the function and env -u strips exported functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more escapes doudouOUC reproduced in the removal model, both verified against the guard first. - A bare `unset NAME` unsets a same-name variable first and removes the function only when none exists. This evaluator tracks no ordinary variables, so it cannot tell the two apart; treating every bare `unset NAME` as a function removal dropped a live relocating shadow (`pwn(){ git -C …; }; pwn=1; unset pwn; pwn`). Only `unset -f` now removes a function; a bare `unset` leaves it, the safe over-deny choice. - A bash `export -f foo` travels as a `BASH_FUNC_foo%%` environment entry, so `env -u BASH_FUNC_foo%%` (and the `--unset=` / attached forms) strips it before `bash -c` and the child runs the real program. The env wrapper now records unset keys in PrefixState and the payload seeding drops functions whose `BASH_FUNC_*` entry was removed, so a stripped harmless `git` shadow no longer masks the real relocation. Adds regressions for both; keeps `unset -f`, unrelated `env -u`, and live shadows behaving as before. --- .../serve/daemon-git-worktree-guard.test.ts | 17 ++++++ .../src/serve/daemon-git-worktree-guard.ts | 56 ++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 7670d5bfc97..2e2d2b5acb9 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2057,6 +2057,19 @@ it -C ${outsideRepo} reset --hard`, // A command substitution inherits the exported function too. () => `evil() { git -C ${plainOutsidePath} reset --hard; }; export -f evil; echo $(bash -c 'evil')`, + // A bare `unset NAME` removes a same-name variable first; Bash keeps the + // function, so the model must not delete it (it tracks no variables). + () => + `pwn() { git -C ${plainOutsidePath} reset --hard; }; pwn=1; unset pwn; pwn`, + // `env -u BASH_FUNC_git%%` (separated, attached, and `--unset=` forms) + // strips the exported function from the child, which then runs the real + // git — the guard must not replay the harmless imported body. + () => + `git() { :; }; export -f git; env -u 'BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env -u'BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + () => + `git() { :; }; export -f git; env --unset='BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, ])( 'does not let a mis-modelled removal drop a live relocating shadow %#', async (build) => { @@ -2084,6 +2097,10 @@ it -C ${outsideRepo} reset --hard`, `git() { :; }; export -f git; env -i bash -c "git -C ${plainOutsidePath} rev-parse HEAD"`, // `env` without a clearing flag keeps the bash-imported shadow live. `git() { :; }; export -f git; env FOO=bar bash -c "git -C ${plainOutsidePath} reset --hard"`, + // `env -u` of an unrelated key leaves the exported function in place. + `git() { :; }; export -f git; env -u FOO bash -c "git -C ${plainOutsidePath} reset --hard"`, + // `env -u BASH_FUNC_other%%` strips a different function, not git. + `git() { :; }; export -f git; env -u 'BASH_FUNC_other%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, ]) { await expect(guard(request(command))).resolves.toEqual({ allowed: true }); } diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 0c33c98e914..34a42725da3 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -282,6 +282,21 @@ interface PrefixState { // `env -i` / `--ignore-environment` wipe the inherited environment, so a // later shell child receives none of the parent's `export -f` functions. clearsEnvironment?: boolean; + // Environment names removed by `env -u` / `--unset`. A bash `export -f foo` + // travels as a `BASH_FUNC_foo%%` entry, so unsetting it strips the function + // from the child even though the environment is otherwise intact. + unsetEnvKeys?: Set; +} + +// Bash exports a function `foo` as a `BASH_FUNC_foo%%` (4.3+) or +// `BASH_FUNC_foo()` (older) environment entry; an `env -u` of that entry drops +// the function from the child even though `-u` names an ordinary key. +function envUnsetRemovesFunction(name: string, state: PrefixState): boolean { + const keys = state.unsetEnvKeys; + return ( + keys !== undefined && + (keys.has(`BASH_FUNC_${name}%%`) || keys.has(`BASH_FUNC_${name}()`)) + ); } type GuardDenial = { allowed: false; reason: string }; @@ -707,6 +722,12 @@ function consumeEnvWrapper( }; } if (ENV_VALUE_FLAGS.has(token.text)) { + // Only `-u`/`--unset` reach here (`-S`/`--split-string` returned above); + // remember the removed key so a stripped `BASH_FUNC_*` is honoured. + const removed = run[index + 1]; + if (removed !== undefined && !removed.dynamic) { + (state.unsetEnvKeys ??= new Set()).add(removed.text); + } index += 2; continue; } @@ -722,6 +743,10 @@ function consumeEnvWrapper( const rest = joinArgvTexts(run.slice(index + 1)); return { next: run.length, payload: rest ? `${fused} ${rest}` : fused }; } + const removedName = token.text.startsWith('--unset=') + ? token.text.slice('--unset='.length) + : token.text.slice(2); + (state.unsetEnvKeys ??= new Set()).add(removedName); index++; continue; } @@ -2213,8 +2238,12 @@ async function evaluateCommandWithCwd( !definedBodies.has(removalProgram) ) { const removesAliases = removalProgram === 'unalias'; - const removesVariablesOnly = - !removesAliases && run.some((token) => token.text === '-v'); + // Only `unset -f` removes a function. A bare `unset NAME` unsets a + // same-name variable first and touches the function only when none + // exists; this evaluator does not track ordinary variables, so it + // cannot tell — leaving the function is the safe (over-deny) choice. + const removesFunctions = + !removesAliases && run.some((token) => token.text === '-f'); if ( removesAliases && run.some((token) => token.text === '-a' || token.text === '-af') @@ -2223,15 +2252,15 @@ async function evaluateCommandWithCwd( for (const [name, entry] of [...definedBodies]) { if (entry.alias) definedBodies.delete(name); } - } else if (!removesVariablesOnly) { + } else if (removesAliases || removesFunctions) { for (const token of run.slice(1)) { if (token.text.startsWith('-')) continue; const entry = definedBodies.get(token.text); if (removesAliases) { if (entry?.alias) definedBodies.delete(token.text); } else if (!entry?.alias) { - // `unset -f NAME` / bare `unset NAME`: a function and its - // git/export attributes, never an alias. + // `unset -f NAME`: a function and its git/export attributes, + // never an alias. definedBodies.delete(token.text); gitShapedNames.delete(token.text); exportedFunctions.delete(token.text); @@ -2345,13 +2374,22 @@ async function evaluateCommandWithCwd( ? { // Only bash imports `export -f` functions. A `-c` // subprocess is a separate process: copy the set so a - // child `unset -f` cannot retract the parent's exports. + // child `unset -f` cannot retract the parent's exports, + // and drop any function whose `BASH_FUNC_*` entry an + // `env -u` stripped before the child started. definedBodies: new Map( - [...definedBodies].filter(([name]) => - exportedFunctions.has(name), + [...definedBodies].filter( + ([name]) => + exportedFunctions.has(name) && + !envUnsetRemovesFunction(name, analysis.state), + ), + ), + exportedFunctions: new Set( + [...exportedFunctions].filter( + (name) => + !envUnsetRemovesFunction(name, analysis.state), ), ), - exportedFunctions: new Set(exportedFunctions), } : {}), }, From 5fc219db27e20d389df858232d058bf8639d24d7 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 13 Aug 2026 16:07:00 +0800 Subject: [PATCH 40/45] fix(daemon): fail closed when a removal builtin could retract a tracked shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modelling exactly which definition an `unset`/`unalias`/`export -n` removes is general shell semantics this guard does not attempt: a bare `unset NAME` drops a same-name variable before the function, `enable -n unset` turns the builtin into a no-op, a `command`/`builtin` prefix or a `( … )` subshell changes what runs, and fused flag clusters (`-nf`) hide the mode. Every attempt to model these precisely left a live relocating shadow reachable through a form it did not cover. Collapse the whole removal path to one rule: when a removal references a name tracked as a shadow (a defined body, a git-shaped name, or an exported function) — or clears all while any shadow exists — fail closed. This denies the previously-allowed `git(){ :; }; unset git; git -C …`, `export -nf`, `command unset -f`, `enable -n unset; unset -f`, and `( unset -f git ); git` forms, while a removal of an untracked name and every live-shadow replay behave exactly as before. Removes the earlier kind-aware bookkeeping the same escapes kept slipping through. --- .../serve/daemon-git-worktree-guard.test.ts | 16 +++ .../src/serve/daemon-git-worktree-guard.ts | 106 +++++++++--------- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 2e2d2b5acb9..7fa1d7cfad3 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2070,6 +2070,22 @@ it -C ${outsideRepo} reset --hard`, `git() { :; }; export -f git; env -u'BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, () => `git() { :; }; export -f git; env --unset='BASH_FUNC_git%%' bash -c "git -C ${plainOutsidePath} reset --hard"`, + // A bare `unset git` with no same-name variable removes the function in + // bash; the harmless body must not mask the relocating call arguments. + () => `git() { :; }; unset git; git -C ${plainOutsidePath} reset --hard`, + // Fused `export -nf` un-exports the function bash's option parser accepts. + () => + `git() { :; }; export -f git; export -nf git; bash -c 'git -C ${plainOutsidePath} reset --hard'`, + // A `command`/`builtin` prefix still runs the real removal builtin. + () => + `git() { :; }; command unset -f git; git -C ${plainOutsidePath} reset --hard`, + // `enable -n unset` disables the builtin, so the removal is a no-op and + // the relocating function survives. + () => + `g() { git -C ${plainOutsidePath} reset --hard; }; enable -n unset; unset -f g; g`, + // A removal inside a `( … )` subshell does not reach the parent shell. + () => + `git() { command git -C ${plainOutsidePath} reset --hard "$@"; }; ( unset -f git ); git`, ])( 'does not let a mis-modelled removal drop a live relocating shadow %#', async (build) => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 34a42725da3..976c88bd46f 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -246,6 +246,8 @@ const UNDECIDABLE_PAYLOAD_DENIAL = 'Daemon shell guard denied a shell command whose payload could not be resolved before execution.'; const UNRECOGNIZED_PROGRAM_DENIAL = 'Daemon shell guard denied a shell command that may run a relocated Git command through an unrecognized program.'; +const SHADOW_REMOVAL_DENIAL = + 'Daemon shell guard denied a shell command that removes a tracked shell definition in a way it cannot model.'; const PROMPTLESS_PROVIDER_DENIAL = 'Managed external tool guard cannot consult an external provider without an active prompt binding.'; @@ -2224,62 +2226,64 @@ async function evaluateCommandWithCwd( restoreShellState(subshellCwds.pop()); subshellDepth--; } - // Removal builtins retract earlier definitions, but only the real - // builtin does: a function shadowing `unset`/`unalias`/`export` runs - // instead, so those fall through to the shadow dispatch below and remove - // nothing. `unalias` touches only aliases; `unset -f` (or a bare - // `unset NAME`, which falls back to the function when no variable - // shadows it) touches only functions; `unset` has no `-a` option, so it - // never clears wholesale — mis-modelling any of these would drop a live - // relocating shadow and allow the command. - const removalProgram = readProgramWord(run); - if ( - (removalProgram === 'unset' || removalProgram === 'unalias') && - !definedBodies.has(removalProgram) + // A removal builtin (`unset`/`unalias`/`export -n`) retracts a shadow, + // but deciding exactly which name it drops is general shell semantics + // this guard does not model: `unset NAME` removes a same-name variable + // before the function, `enable -n unset` turns the builtin into a no-op, + // a `command`/`builtin` prefix or a `( … )` subshell changes what runs, + // and fused flag clusters (`-nf`) hide the mode. Whenever a removal + // could retract a name we track as a shadow, fail closed rather than + // trust a now-doubtful replay of the harmless body. + let removalStart = 0; + while ( + removalStart < run.length && + (run[removalStart]!.text === 'command' || + run[removalStart]!.text === 'builtin') ) { - const removesAliases = removalProgram === 'unalias'; - // Only `unset -f` removes a function. A bare `unset NAME` unsets a - // same-name variable first and touches the function only when none - // exists; this evaluator does not track ordinary variables, so it - // cannot tell — leaving the function is the safe (over-deny) choice. - const removesFunctions = - !removesAliases && run.some((token) => token.text === '-f'); - if ( - removesAliases && - run.some((token) => token.text === '-a' || token.text === '-af') + removalStart++; + while ( + removalStart < run.length && + run[removalStart]!.text.startsWith('-') ) { - // `unalias -a` clears every alias but leaves functions intact. - for (const [name, entry] of [...definedBodies]) { - if (entry.alias) definedBodies.delete(name); - } - } else if (removesAliases || removesFunctions) { - for (const token of run.slice(1)) { - if (token.text.startsWith('-')) continue; - const entry = definedBodies.get(token.text); - if (removesAliases) { - if (entry?.alias) definedBodies.delete(token.text); - } else if (!entry?.alias) { - // `unset -f NAME`: a function and its git/export attributes, - // never an alias. - definedBodies.delete(token.text); - gitShapedNames.delete(token.text); - exportedFunctions.delete(token.text); - } - } + removalStart++; } - continue; } - if ( - removalProgram === 'export' && - !definedBodies.has('export') && - run.some((token) => token.text === '-n') && - run.some((token) => token.text === '-f') - ) { - for (const token of run.slice(1)) { - if (!token.text.startsWith('-') && !token.dynamic) { - exportedFunctions.delete(token.text); - } + const removalTokens = run.slice(removalStart); + const removalProgram = readProgramWord(removalTokens); + const isRemoval = + removalProgram === 'unset' || + removalProgram === 'unalias' || + (removalProgram === 'export' && + removalTokens.some((token) => /^-[A-Za-z]*n/.test(token.text))); + if (isRemoval) { + const clearsAll = removalTokens.some((token) => + /^-[A-Za-z]*a/.test(token.text), + ); + const touchesShadow = (name: string): boolean => + definedBodies.has(name) || + gitShapedNames.has(name) || + exportedFunctions.has(name); + const anyShadow = + definedBodies.size > 0 || + gitShapedNames.size > 0 || + exportedFunctions.size > 0; + const retractsShadow = + (clearsAll && anyShadow) || + removalTokens + .slice(1) + .some( + (token) => + !token.text.startsWith('-') && + (token.dynamic || touchesShadow(token.text)), + ); + if (retractsShadow) { + return { + denial: { allowed: false, reason: SHADOW_REMOVAL_DENIAL }, + cwdAfter: trackedCwd, + }; } + // A removal that names only untracked variables is a genuine no-op for + // the shadow model. continue; } // A recorded function shadows a builtin or the git program, and bash From 6128e699ae096ba5155c8dea33057fdc02a1b969 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 13 Aug 2026 19:06:47 +0800 Subject: [PATCH 41/45] fix(daemon): skip leading redirections before the removal-builtin prefix scan The `command`/`builtin` strip in the shadow-removal guard started at raw token zero, but bash strips redirections from argv. A leading `2>/dev/null` before `command unset -f ` left the scan looking at the redirect operand, so `command` was never consumed, `readProgramWord` returned `command` rather than `unset`, the removal went unrecorded, and the stale harmless function masked the later external Git relocation. Skip redirect/fd operands before and between the `command`/`builtin` prefixes, the same normalization `readProgramWord` applies. Adds the leading-redirection variant to the command-prefix regression. --- .../src/serve/daemon-git-worktree-guard.test.ts | 4 ++++ .../cli/src/serve/daemon-git-worktree-guard.ts | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 7fa1d7cfad3..2ed4efa7efc 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2079,6 +2079,10 @@ it -C ${outsideRepo} reset --hard`, // A `command`/`builtin` prefix still runs the real removal builtin. () => `git() { :; }; command unset -f git; git -C ${plainOutsidePath} reset --hard`, + // A leading redirection is stripped from argv, so it must not hide the + // `command unset` that removes the shadow. + () => + `git() { :; }; 2>/dev/null command unset -f git; git -C ${plainOutsidePath} reset --hard`, // `enable -n unset` disables the builtin, so the removal is a no-op and // the relocating function survives. () => diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 976c88bd46f..c3189ac2dd1 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -2234,7 +2234,20 @@ async function evaluateCommandWithCwd( // and fused flag clusters (`-nf`) hide the mode. Whenever a removal // could retract a name we track as a shadow, fail closed rather than // trust a now-doubtful replay of the harmless body. + // Bash strips redirections from argv, so skip redirect/fd operands the + // same way `readProgramWord` does before (and between) `command`/ + // `builtin` prefixes — otherwise a leading `2>/dev/null` hides the + // `command unset` that really removes the shadow. let removalStart = 0; + const skipRedirectOperands = (): void => { + while ( + removalStart < run.length && + (run[removalStart]!.redirect || run[removalStart]!.ambiguousFd) + ) { + removalStart++; + } + }; + skipRedirectOperands(); while ( removalStart < run.length && (run[removalStart]!.text === 'command' || @@ -2243,7 +2256,9 @@ async function evaluateCommandWithCwd( removalStart++; while ( removalStart < run.length && - run[removalStart]!.text.startsWith('-') + (run[removalStart]!.text.startsWith('-') || + run[removalStart]!.redirect || + run[removalStart]!.ambiguousFd) ) { removalStart++; } From d95d87ca24b5fcf3560301bd17302bcc5d92c722 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 13 Aug 2026 19:39:00 +0800 Subject: [PATCH 42/45] fix(daemon): replay a shadowed removal builtin and drop unset variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more escapes doudouOUC reproduced in the fail-closed removal rule. - The rule's early `continue` fired even when `unset`/`unalias`/`export` was itself a recorded function and the operands named only untracked state, so a shadowing `unset(){ git -C …; }; unset other` was classified as a harmless builtin removal and never reached the shadow dispatch that replays the relocating body. The branch now runs only when the program is not a shadowed function (a `command`/`builtin` prefix still forces the builtin). - The removal never dropped tracked variables, so `A=nested; unset A; cd $A` kept expanding the stale in-bounds value while bash's `unset A` leaves `$A` empty and `cd $A` lands at $HOME. `unset NAME`/`unset -v NAME` now deletes the shell-local, turning the later `$A` into an unresolved reference the cd fails closed on. `unset -f` is functions-only and leaves variables intact. Adds regressions for both. --- .../serve/daemon-git-worktree-guard.test.ts | 6 ++++ .../src/serve/daemon-git-worktree-guard.ts | 34 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 2ed4efa7efc..e6abbe708ba 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -2090,6 +2090,12 @@ it -C ${outsideRepo} reset --hard`, // A removal inside a `( … )` subshell does not reach the parent shell. () => `git() { command git -C ${plainOutsidePath} reset --hard "$@"; }; ( unset -f git ); git`, + // A function shadowing `unset` runs its relocating body even when the + // argument names only untracked state — the builtin never runs. + () => `unset() { git -C ${plainOutsidePath} reset --hard; }; unset other`, + // `unset A` drops the tracked variable, so `cd $A` is a bare `cd` to $HOME + // in bash; the guard must not keep expanding the stale in-bounds value. + () => `A=nested; unset A; cd $A; git reset --hard`, ])( 'does not let a mis-modelled removal drop a live relocating shadow %#', async (build) => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index c3189ac2dd1..2db811df47e 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -2248,11 +2248,13 @@ async function evaluateCommandWithCwd( } }; skipRedirectOperands(); + let hasCommandPrefix = false; while ( removalStart < run.length && (run[removalStart]!.text === 'command' || run[removalStart]!.text === 'builtin') ) { + hasCommandPrefix = true; removalStart++; while ( removalStart < run.length && @@ -2265,11 +2267,20 @@ async function evaluateCommandWithCwd( } const removalTokens = run.slice(removalStart); const removalProgram = readProgramWord(removalTokens); + // A function shadowing `unset`/`unalias`/`export` runs its body instead + // of the builtin (unless `command`/`builtin` bypassed the lookup), so + // let the normal shadow dispatch replay it rather than treating the run + // as a builtin removal that changes nothing. + const shadowedBuiltin = + !hasCommandPrefix && + removalProgram !== undefined && + definedBodies.has(removalProgram); const isRemoval = - removalProgram === 'unset' || - removalProgram === 'unalias' || - (removalProgram === 'export' && - removalTokens.some((token) => /^-[A-Za-z]*n/.test(token.text))); + !shadowedBuiltin && + (removalProgram === 'unset' || + removalProgram === 'unalias' || + (removalProgram === 'export' && + removalTokens.some((token) => /^-[A-Za-z]*n/.test(token.text)))); if (isRemoval) { const clearsAll = removalTokens.some((token) => /^-[A-Za-z]*a/.test(token.text), @@ -2297,8 +2308,19 @@ async function evaluateCommandWithCwd( cwdAfter: trackedCwd, }; } - // A removal that names only untracked variables is a genuine no-op for - // the shadow model. + // `unset NAME` / `unset -v NAME` drops a tracked variable, so a later + // `$NAME` must stop expanding to its stale value — bash leaves it empty + // (an unresolved reference the guard then fails closed on). `unset -f` + // is functions-only and leaves variables intact. + if ( + removalProgram === 'unset' && + !removalTokens.some((token) => token.text === '-f') + ) { + for (const token of removalTokens.slice(1)) { + if (!token.text.startsWith('-')) shellLocals.delete(token.text); + } + } + // Any other removal that names only untracked state is a genuine no-op. continue; } // A recorded function shadows a builtin or the git program, and bash From a5f809978f6c62cc451ca1449be07c3a4dbd6eb6 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 02:51:55 +0800 Subject: [PATCH 43/45] fix(daemon): honor shadowed command/builtin prefixes and PATH-based relocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two escapes surfaced by the round-11 review, both reproduced against the guard. - The shadow-removal prefix scan trusted a literal `command`/`builtin` word to force the real builtin, but bash resolves a function of that name first. A `command(){ git -C …; }; command unset other` therefore early-continued as a harmless builtin removal and never replayed the relocating body. The prefix loop now stops when the prefix word is itself a recorded shadow, leaving it for the normal shadow dispatch. - The unrecognized-program marker scans covered GIT_DIR/GIT_WORK_TREE-family assignments but not GIT_PROGRAM_ENV_KEYS (`PATH`/`GIT_EXEC_PATH`), which decide which git binary runs. The direct `PATH=/evil git …` was denied while `find … -exec sh -c 'PATH=/evil git …'` slipped through. Both marker scans now include those keys, and they remain gated on a co-present git word so an ordinary `PATH=… make` is unaffected. Adds regressions for the shadowed prefixes and the wrapped PATH/GIT_EXEC_PATH forms. --- .../src/serve/daemon-git-worktree-guard.test.ts | 10 ++++++++++ .../cli/src/serve/daemon-git-worktree-guard.ts | 14 +++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index e6abbe708ba..e56fb34068b 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -744,6 +744,10 @@ it -C ${outsideRepo} reset --hard`, () => `xargs -I{} git -C ${outsideRepo} reset --hard`, () => `su -c 'git -C ${outsideRepo} reset --hard'`, () => `find . -exec git -C ${outsideRepo} reset --hard ;`, + // `PATH=`/`GIT_EXEC_PATH=` inside an unrecognized wrapper choose which git + // binary runs — the direct forms are denied, so the wrapper must be too. + () => `find . -exec sh -c 'PATH=/tmp/evil git reset --hard' ';'`, + () => `find . -exec sh -c 'GIT_EXEC_PATH=/tmp/evil git reset --hard' ';'`, ])( 'fails closed when an unrecognized program may run a relocated Git command %#', async (buildCommand) => { @@ -2096,6 +2100,12 @@ it -C ${outsideRepo} reset --hard`, // `unset A` drops the tracked variable, so `cd $A` is a bare `cd` to $HOME // in bash; the guard must not keep expanding the stale in-bounds value. () => `A=nested; unset A; cd $A; git reset --hard`, + // A function shadowing the `command`/`builtin` prefix word runs its own + // relocating body — the prefix is not a guaranteed bypass to the builtin. + () => + `command() { git -C ${plainOutsidePath} reset --hard; }; command unset other`, + () => + `builtin() { git -C ${plainOutsidePath} reset --hard; }; builtin unset other`, ])( 'does not let a mis-modelled removal drop a live relocating shadow %#', async (build) => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 2db811df47e..78b826852e2 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -564,7 +564,11 @@ function hasGitRelocationMarker(tokens: GuardToken[]): boolean { const key = leadingEnvAssignmentKey(token.text); return ( key !== null && - (GIT_DIR_ENV_KEYS.has(key) || GIT_WORK_TREE_ENV_KEYS.has(key)) + (GIT_DIR_ENV_KEYS.has(key) || + GIT_WORK_TREE_ENV_KEYS.has(key) || + // `PATH=`/`GIT_EXEC_PATH=` choose which git binary runs — a relocation + // the direct path already denies, so the wrapper backstop must too. + GIT_PROGRAM_ENV_KEYS.has(key)) ); }); } @@ -579,9 +583,9 @@ const GIT_WORD_PATTERN = /\bgit\b/i; // A `cd`/`pushd` inside such a payload relocates the git that follows it just // as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). const TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN = - /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; const TEXT_RELOCATION_MARKER_PATTERN = - /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE)\+?=/; + /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; // Assignments that decide WHICH git binary the run executes. The guard // classifies the program word `git` and then reasons about paths; if the @@ -2254,6 +2258,10 @@ async function evaluateCommandWithCwd( (run[removalStart]!.text === 'command' || run[removalStart]!.text === 'builtin') ) { + // Bash resolves a function before the `command`/`builtin` builtin, so + // a shadowed prefix word runs its own body — leave it for the shadow + // dispatch rather than treating it as a bypass to the real builtin. + if (definedBodies.has(run[removalStart]!.text)) break; hasCommandPrefix = true; removalStart++; while ( From c7b6aa4878d0a8fde4f6165fce1b973c5d4cf873 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 10:17:08 +0800 Subject: [PATCH 44/45] fix(daemon): catch delimiter-glued relocations and redirect-decoy prefix drops Two escapes surfaced by the round-12 review, both reproduced against the guard. - The env-assignment arm of both text marker patterns required `(^|\s)` before the key, while the sibling `cd`/`pushd` arm already allowed `;&|(){}` boundaries. A relocation glued to a delimiter inside a quoted wrapper payload (`su -c 'true;GIT_DIR= git reset --hard'`) therefore evaded the unrecognized-program backstop. Both arms now share the same boundary class. - `invokeDefinedBody` located the invoked name with a raw `findIndex` that also matched redirect operands, so a decoy `> g` whose target equals the function name truncated the prefix-assignment scan to empty and dropped the call's `GIT_DIR=` relocation. The lookup now skips redirect/fd operands like `readProgramWord` does. Adds regressions for the delimiter-glued and redirect-decoy forms. --- .../cli/src/serve/daemon-git-worktree-guard.test.ts | 7 +++++++ packages/cli/src/serve/daemon-git-worktree-guard.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index e56fb34068b..0f43f03255c 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -748,6 +748,10 @@ it -C ${outsideRepo} reset --hard`, // binary runs — the direct forms are denied, so the wrapper must be too. () => `find . -exec sh -c 'PATH=/tmp/evil git reset --hard' ';'`, () => `find . -exec sh -c 'GIT_EXEC_PATH=/tmp/evil git reset --hard' ';'`, + // A relocation assignment glued to a shell delimiter inside a quoted + // payload must still register as a marker, matching the `cd`/`pushd` arm. + () => `su -c 'true;GIT_DIR=${outsideRepo}/.git git reset --hard'`, + () => `su -c 'x && GIT_WORK_TREE=${outsideRepo} git reset --hard'`, ])( 'fails closed when an unrecognized program may run a relocated Git command %#', async (buildCommand) => { @@ -1558,6 +1562,9 @@ it -C ${outsideRepo} reset --hard`, // A body defined earlier runs where the later bare word appears. () => `alias g='git reset --hard'; cd ${outsideRepo}; g`, () => `f() { git reset --hard; }; cd ${outsideRepo}; f`, + // A decoy `> g` redirect whose target equals the function name must not + // truncate the prefix-assignment scan of the `GIT_DIR=` on the call. + () => `g() { git reset --hard; }; > g GIT_DIR=${outsideRepo}/.git g`, ])('closes the round-6 escapes %#', async (build) => { const guard = createDaemonToolGuard(); diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index 78b826852e2..f103c995c31 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -583,9 +583,9 @@ const GIT_WORD_PATTERN = /\bgit\b/i; // A `cd`/`pushd` inside such a payload relocates the git that follows it just // as effectively as a `-C` flag (`su -c 'cd && git reset --hard'`). const TEXT_RELOCATION_MARKER_WITHOUT_C_PATTERN = - /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; + /(^|\s)(--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|[\s;&|(){}])(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; const TEXT_RELOCATION_MARKER_PATTERN = - /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|\s)(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; + /(^|\s)(-C|--git-dir=?|--work-tree=?|-execdir)|(^|[\s;&|(){}])(cd|pushd)([\s;&|]|$)|(^|[\s;&|(){}])(GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_INDEX_FILE|GIT_EXEC_PATH|PATH)\+?=/; // Assignments that decide WHICH git binary the run executes. The guard // classifies the program word `git` and then reasons about paths; if the @@ -2054,7 +2054,13 @@ async function evaluateCommandWithCwd( const defined = definedBodies.get(programToken)!; if (depth >= MAX_PAYLOAD_RECURSION_DEPTH) return denyDynamicRelocation(); let replay = defined.body; - const programIndex = run.findIndex((token) => token.text === programToken); + // Skip redirect/fd operands the way `readProgramWord` does, so a decoy + // `> name` before the call does not truncate the prefix-assignment scan and + // drop the call's leading `VAR=val` relocations. + const programIndex = run.findIndex( + (token) => + !token.redirect && !token.ambiguousFd && token.text === programToken, + ); if (defined.alias) { const args = joinArgvTexts(run.slice(programIndex + 1)); if (args.length > 0) replay = `${replay} ${args}`; From a0427ae032aaaa12f2ed97ecf5a155262199c8d5 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 17:19:53 +0800 Subject: [PATCH 45/45] fix(daemon): deny trailer/man/sendemail command-executing config keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dangerous-config model already denies `git -c =` for the command-executing config families, but omitted three documented ones: `trailer..command`, `man..cmd`, and `sendemail.(sendmailcmd|tocmd|cccmd)`. `git -c trailer.sign.command='…' interpret-trailers` (and the man/sendemail forms) ran the configured shell command while the guard allowed it. Adds the three patterns and regressions. --- packages/cli/src/serve/daemon-git-worktree-guard.test.ts | 4 ++++ packages/cli/src/serve/daemon-git-worktree-guard.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts index 0f43f03255c..50080dfd38f 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.test.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.test.ts @@ -474,6 +474,10 @@ it -C ${outsideRepo} reset --hard`, 'git -c core.editor=evil-command commit', 'git --config-env core.pager=evil-command log --follow', 'git -c filter.evil.clean=evil-command add file', + // Command-executing config families git runs directly. + "git -c trailer.sign.command='evil-command' interpret-trailers", + "git -c man.foo.cmd='evil-command' help -m git", + "git -c sendemail.sendmailcmd='evil-command' send-email", ])( 'denies mutating subcommands with command-valued -c config', async (command) => { diff --git a/packages/cli/src/serve/daemon-git-worktree-guard.ts b/packages/cli/src/serve/daemon-git-worktree-guard.ts index f103c995c31..39ce8229028 100644 --- a/packages/cli/src/serve/daemon-git-worktree-guard.ts +++ b/packages/cli/src/serve/daemon-git-worktree-guard.ts @@ -89,6 +89,9 @@ const GIT_COMMAND_CONFIG_KEY_PATTERNS = [ /^remote\..+\.(proxy|receivepack|uploadpack)$/, /^ssh\.variant$/, /^tar\..+\.command$/, + /^trailer\..+\.command$/, + /^man\..+\.cmd$/, + /^sendemail\.(sendmailcmd|tocmd|cccmd)$/, /^web\.browser$/, // Pulls in a config file the guard cannot read: it can carry a // `core.worktree` redirect or any command-executing key, so it is