From 937aef2c719e28aeecfe23cd351449b186bc6479 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 13 Apr 2026 07:13:00 -0500 Subject: [PATCH] fix(claude-code): default GSD subagents to bypassPermissions and pre-authorize safe built-ins (#4099 follow-up) The first pass at #4099 only pre-authorized `mcp____*` tools, but in `acceptEdits` mode the SDK still gates Read, Write, Glob/Grep, and basic shell inspection commands like `ls`. GSD subagents need the full workflow toolset and were still hitting "This command requires approval" prompts on every tool call. Two changes: 1. `resolveClaudePermissionMode` now returns `bypassPermissions` for all GSD subagent runs (auto + interactive), dropping the `acceptEdits` branch and the `isAutoActive` dynamic import. The host Claude Code session's permission model is the user-visible gate; the inner SDK process re-prompting on every tool was approval fatigue with no net safety benefit. `GSD_CLAUDE_CODE_PERMISSION_MODE` env override stays so security-conscious users can opt back into a stricter mode. 2. Expanded the pre-authorized `allowedTools` list to include Read, Write, Edit, Glob, Grep, `Bash(ls:*)`, and `Bash(pwd)` alongside the MCP server globs. Acts as a belt-and-suspenders safety net for users who set the env override to `acceptEdits`. Tradeoff documented inline: bypass means a prompt-injection payload read from an untrusted file could trigger tool calls without a second gate. Accepted because the workflow is explicit user intent and the alternative is continuous approval fatigue that blocks real work. Tests updated for the new allowedTools shape; permission-mode tests already accepted bypass as the default. Co-Authored-By: Claude Opus 4.6 --- .../claude-code-cli/stream-adapter.ts | 53 ++++++++++--------- .../tests/stream-adapter.test.ts | 22 +++++++- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 70c30e641..d8d3e35f5 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -562,15 +562,18 @@ export function makeAbortedMessage(model: string, lastTextContent: string): Assi /** * Resolve the Claude Code permission mode for the current run. * - * - Auto-mode / headless runs bypass permissions so tool calls don't block - * on prompts the user isn't watching. - * - Interactive runs default to `acceptEdits` so file/bash writes still - * land quickly but the SDK retains a permission gate. - * - `GSD_CLAUDE_CODE_PERMISSION_MODE` forces a specific mode when set. + * GSD subagents run underneath a host Claude Code session the user has + * already consented to, and their work (edits, shell inspection, MCP calls) + * spans the full workflow toolset. Defaulting the inner SDK to + * `bypassPermissions` avoids per-tool approval prompts that offer no + * meaningful safety beyond what the host session and the subagent prompts + * already enforce. `GSD_CLAUDE_CODE_PERMISSION_MODE` lets security-conscious + * users opt into a stricter mode (`acceptEdits`, `default`, `plan`). * - * Cross-extension coupling is kept minimal by dynamically importing - * `isAutoActive` and falling back to the bypass default if the import - * fails (e.g. in unit tests that load stream-adapter in isolation). + * Tradeoff: bypass means a prompt-injection payload read from an untrusted + * file could trigger tool calls without a second gate. Accepted for GSD + * because the workflow is explicit user intent and the alternative + * (#4099) is continuous approval fatigue that blocks real work. */ export async function resolveClaudePermissionMode( env: NodeJS.ProcessEnv = process.env, @@ -579,17 +582,7 @@ export async function resolveClaudePermissionMode( if (override === "bypassPermissions" || override === "acceptEdits" || override === "default" || override === "plan") { return override; } - - try { - const autoMod = (await import("../gsd/auto.js")) as { isAutoActive?: () => boolean }; - if (typeof autoMod.isAutoActive === "function" && autoMod.isAutoActive()) { - return "bypassPermissions"; - } - return "acceptEdits"; - } catch { - // auto.ts unavailable (tests, non-GSD contexts) — stay permissive. - return "bypassPermissions"; - } + return "bypassPermissions"; } /** @@ -612,13 +605,21 @@ export function buildSdkOptions( const mcpServers = buildWorkflowMcpServers(); const permissionMode = overrides?.permissionMode ?? "bypassPermissions"; const disallowedTools = ["AskUserQuestion"]; - // Pre-authorize every registered workflow MCP server's tools. Without this, - // `acceptEdits` mode (the interactive default) auto-approves built-in - // Edit/Write/Bash but still gates MCP calls like `mcp__gsd-workflow__*`, - // surfacing "This command requires approval" on every GSD action (#4099). - const allowedTools = mcpServers - ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) - : []; + // Pre-authorize the safe built-ins and every registered workflow MCP + // server's tools. `acceptEdits` mode (the interactive default) only + // auto-approves file edits — Read/Glob/Grep, basic shell inspection, and + // every `mcp__gsd-workflow__*` call still surface as "This command + // requires approval" and block GSD actions (#4099). + const allowedTools = [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash(ls:*)", + "Bash(pwd)", + ...(mcpServers ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) : []), + ]; return { pathToClaudeCodeExecutable: getClaudePath(), model: modelId, diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index 3a9ce8934..a600852a4 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -369,7 +369,16 @@ describe("stream-adapter — session persistence (#2859)", () => { assert.equal(srv.env.GSD_PERSIST_WRITE_GATE_STATE, "1"); assert.equal(srv.env.GSD_WORKFLOW_PROJECT_ROOT, "/tmp/project"); assert.deepEqual(options.disallowedTools, ["AskUserQuestion"]); - assert.deepEqual(options.allowedTools, ["mcp__gsd-workflow__*"]); + assert.deepEqual(options.allowedTools, [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash(ls:*)", + "Bash(pwd)", + "mcp__gsd-workflow__*", + ]); } finally { process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; @@ -398,7 +407,16 @@ describe("stream-adapter — session persistence (#2859)", () => { const mcpServers = options.mcpServers as Record; assert.ok(mcpServers?.["custom-workflow"], "expected custom workflow server config"); assert.deepEqual(options.disallowedTools, ["AskUserQuestion"]); - assert.deepEqual(options.allowedTools, ["mcp__custom-workflow__*"]); + assert.deepEqual(options.allowedTools, [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash(ls:*)", + "Bash(pwd)", + "mcp__custom-workflow__*", + ]); } finally { process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME;