feat(claude-code): add permission prompt routing for approve mode#7420
feat(claude-code): add permission prompt routing for approve mode#7420codefromthecrypt merged 5 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements GooseMode::Approve support for the claude-code provider, enabling user-approval workflows for tool execution. Previously, only Auto and SmartApprove modes worked, while Approve mode would return an error.
Changes:
- Adds stdio-based permission routing using Claude CLI's
--permission-prompt-tool stdioflag, following the same pattern used by official Claude Agent SDKs - Extends the
Providertrait with optionalpermission_routing()andhandle_permission_confirmation()methods that default to backward-compatible no-op behavior - Routes permission checks through the Agent's confirmation handling, trying the provider first before falling through to the legacy confirmation channel
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| crates/goose/src/providers/claude_code.rs | Implements control protocol structures and permission handling; adds Initialize request on spawn; converts spawn_process to async; cleans up pending permissions on new streams |
| crates/goose/src/providers/base.rs | Adds PermissionRouting enum and new Provider trait methods with backward-compatible defaults |
| crates/goose/src/agents/agent.rs | Updates handle_confirmation to try provider-specific routing first; adds supports_action_required_permissions() method; includes unit tests |
| crates/goose-cli/src/session/mod.rs | Ensures Cancel permission sends DenyOnce confirmation to provider before breaking stream |
| crates/goose/tests/providers.rs | Adds permission approval/denial integration tests for all providers (except codex); updates test infrastructure to support permission testing |
6e30265 to
d412601
Compare
|
Signed-off-by: Adrian Cole <adrian@tetrate.io>
Signed-off-by: Adrian Cole <adrian@tetrate.io>
5c1ee8c to
3078280
Compare
Signed-off-by: Adrian Cole <adrian@tetrate.io>
| let confirmation = rx.await.unwrap_or(PermissionConfirmation { | ||
| principal_type: PrincipalType::Tool, | ||
| permission: Permission::Cancel, | ||
| }); |
There was a problem hiding this comment.
When the oneshot receiver is dropped (e.g., stream cancelled), rx.await returns Err(RecvError), which triggers unwrap_or with Permission::Cancel. However, Permission::Cancel is then handled by the catch-all _ branch that denies the request, sending the same "User denied the tool call" message as an explicit deny. This conflates stream cancellation with explicit user denial, which could be confusing in logs. Consider using a more specific deny message when the receiver is dropped to distinguish between "cancelled due to stream drop" vs "user explicitly denied".
| ) { | ||
| let provider = self.provider.lock().await.clone(); | ||
| if let Some(provider) = provider.as_ref() { | ||
| if provider.permission_routing() == PermissionRouting::ActionRequired |
There was a problem hiding this comment.
can you talk me throught what this is doing? I think I know...
There was a problem hiding this comment.
TL;DR: Confirmations from the user need to get back to whoever is waiting. For LLM providers, that's goose's tool executor. For agentic providers like claude-code, that's the provider's own stream loop. This dispatches to the right one.
Recapping LLM providers not for you, but for me:
LLM providers (Anthropic, OpenAI, etc): goose executes tools itself. When a tool needs approval, dispatch_tool_calls yields an ActionRequired message to the frontend, then blocks on an mpsc channel waiting for the user's decision. When the user allows or denies, handle_confirmation() sends the answer through that channel, which unblocks dispatch_tool_calls to proceed with or skip the tool call
Now, Agentic providers (claude-code, ACP) — the CLI process executes tools. Right now this is only used for Claude-code, but ACP will be similar:
claude-code wants to run a tool in approve mode, it writes a can_use_tool JSON request to stdout via its permission-prompt-tool. The provider's stream method yields an ActionRequired message to the frontend, then blocks on a oneshot channel in pending_confirmations. Now, it can't respond to claude-code until the user decides. When the user allows or denies, handle_confirmation() finds that oneshot channel in pending_confirmations and sends the answer, which unblocks stream, which writes the allow/deny response back to claude-code's stdin.
So, all frontends (CLI, desktop, mobile, ACP) converge on handle_confirmation(). This checks: does the provider handle its own permissions? If yes (agentic), route to the provider's oneshot channel. If not (LLM), fall through to the mpsc channel.
michaelneale
left a comment
There was a problem hiding this comment.
I think makes sense when clean - wasn't totally sure about one bit buy more a Q.
And does this add any types or things we need to cater to in other front ends?
|
|
||
| if permission == Permission::Cancel { | ||
| output::render_text("Tool call cancelled. Returning to chat...", Some(Color::Yellow), true); | ||
| self.agent.handle_confirmation(id.clone(), PermissionConfirmation { |
There was a problem hiding this comment.
will this need to be reflected in desktop or mobile etc?
There was a problem hiding this comment.
No. This code is the CLI's permission prompt and collecting the user's answer.
Desktop/mobile already have their own ToolApprovalButtons POSTs to the confirm_tool_action server endpoint, which calls the same handle_confirmation() method this PR changed. So the routing automatically applies to desktop/mobile: nothing to do here.
Note: When #7238 lands and CLI goes through goosed, this CLI-specific code path becomes dead code; the CLI would POST to confirm_tool_action just like desktop does today.
|
|
||
| if permission == Permission::Cancel { | ||
| output::render_text("Tool call cancelled. Returning to chat...", Some(Color::Yellow), true); | ||
| self.agent.handle_confirmation(id.clone(), PermissionConfirmation { |
There was a problem hiding this comment.
will this need to be reflected in desktop or mobile etc?
There was a problem hiding this comment.
* main: Simplified custom model flow with canonical models (#6934) feat: simplify the text editor to be more like pi (#7426) docs: add YouTube short embed to Neighborhood extension tutorial (#7456) fix: flake.nix build failure and deprecation warning (#7408) feat(claude-code): add permission prompt routing for approve mode (#7420) docs: generate manpages (#7443) Blog/goose v1 25 0 release (#7433) fix: detect truncated LLM responses in apps extension (#7354) fix: removed unnecessary version for goose acp macro dependency (#7428) add flag to hide select voice providers (#7406) New navigation settings layout options and styling (#6645) refactor: MCP-compliant theme tokens and CSS class rename (#7275) Redirect llama.cpp logs through tracing to avoid polluting CLI stdout/stderr (#7434) refactor: change open recipe in new window to pass recipe id (#7392) fix: handle truncated tool calls that break conversation alternation (#7424) streamline some github actions (#7430) Enable bedrock prompt cache (#6710) fix: use BEGIN IMMEDIATE to prevent SQLite deadlocks (#7429) Display working dir (#7419)
* main: (171 commits) fix: TLDR CLI tab in Neighborhood MCP docs (#7461) fix(summon): restore skill supporting files and directory path in load output (#7457) Simplified custom model flow with canonical models (#6934) feat: simplify the text editor to be more like pi (#7426) docs: add YouTube short embed to Neighborhood extension tutorial (#7456) fix: flake.nix build failure and deprecation warning (#7408) feat(claude-code): add permission prompt routing for approve mode (#7420) docs: generate manpages (#7443) Blog/goose v1 25 0 release (#7433) fix: detect truncated LLM responses in apps extension (#7354) fix: removed unnecessary version for goose acp macro dependency (#7428) add flag to hide select voice providers (#7406) New navigation settings layout options and styling (#6645) refactor: MCP-compliant theme tokens and CSS class rename (#7275) Redirect llama.cpp logs through tracing to avoid polluting CLI stdout/stderr (#7434) refactor: change open recipe in new window to pass recipe id (#7392) fix: handle truncated tool calls that break conversation alternation (#7424) streamline some github actions (#7430) Enable bedrock prompt cache (#6710) fix: use BEGIN IMMEDIATE to prevent SQLite deadlocks (#7429) ...
Summary
This adds
GooseMode::Approvesupport to the claude-code provider. Previously onlyAutoandSmartApproveworked.Approvewas silently unsupported.When
GOOSE_MODE=approve, the provider passes--permission-prompt-tool stdioto the CLI, which routes tool permission checks through the stream-json control protocol ascan_use_toolrequests. The provider surfaces these asActionRequiredevents, waits for user confirmation, and writes back the allow/deny response.This is the same mechanism the official Claude Agent SDKs use. For example, the Python SDK auto-sets
permission_prompt_tool_name="stdio"when acan_use_toolcallback is provided (_internal/client.py:68-69), then passes--permission-prompt-toolto the CLI (_internal/transport/subprocess_cli.py:216-218).The
Providertrait gainspermission_routing()andhandle_permission_confirmation()with backward-compatible defaults so existing providers are unaffected.Agent::handle_confirmationtries the provider first, then falls through to the existingconfirmation_tx.Type of Change
AI Assistance
Testing
CLI
Allow:
Deny:
Related Issues
Fixes #3671
Screenshots/Demos