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__<server>__*` 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 <noreply@anthropic.com>
This commit is contained in:
Jeremy 2026-04-13 07:13:00 -05:00
parent b92fdc7b6f
commit 937aef2c71
2 changed files with 47 additions and 28 deletions

View file

@ -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,

View file

@ -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<string, any>;
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;