Design: Hook-Based Tool Confirmations
Issue: #1104 Author: Bob Date: 2026-01-10 Status: In Progress
Implementation Status
| Phase | Component | Status |
|---|---|---|
| 1 | HookType.TOOL_CONFIRM | ✅ Complete |
| 1 | ConfirmationResult dataclass | ✅ Complete |
| 1 | ToolConfirmHook protocol | ✅ Complete |
| 1 | get_confirmation() function | ✅ Complete |
| 2 | cli_confirm_hook | ✅ Complete |
| 2 | auto_confirm_hook | ✅ Complete |
| 2 | Hook registration in chat.py | ✅ Complete |
| 2 | confirm_bridge utilities | ✅ Complete |
| 2 | confirm_func integration | ✅ Complete |
| 3 | server_confirm_hook | ✅ Complete |
| 3 | HTTP endpoint integration | ✅ Complete |
| 3 | Tests (32 passing) | ✅ Complete |
| 4 | Server context vars for SSE | ✅ Complete |
| 4 | Server hook registration | ✅ Complete |
| 4 | V1 API hook-aware confirm_func | ✅ Complete |
| 5 | Tool migration | ❌ Reverted (see notes) |
| 6 | Simplification & cleanup | ✅ Complete |
| 6.1 | Consolidate preview printing | ✅ Complete |
| 6.2 | Centralize auto-confirm state | ✅ Complete |
| 6.3 | Unify server auto-confirm | ✅ Complete |
| 6.4 | Consolidate help text | ✅ Complete |
| 7 | Remove ask_execute fallback | ✅ Complete |
| 7.1 | Simplify confirm_func | ✅ Complete |
| 7.2 | Move CLI hook to init_hooks | ✅ Complete |
| 7.3 | Use contextvars for auto-confirm | ✅ Complete |
| 8 | Tool auto-approve via ToolSpec hooks | ✅ Complete |
| 8.1 | Hook fall-through support | ✅ Complete |
| 8.2 | Shell allowlist hook | ✅ Complete |
| 8.3 | Tests for fall-through & allowlist | ✅ Complete |
Current state: Phases 1-4, 6, 7, 8 complete. Phase 5 was reverted.
Implemented:
confirm_funcinchat.pyalways uses hooks (noask_executefallback)confirm_funcinapi.py(v1) uses hooks when available, falling back to auto-confirm- Server's HTTP endpoint resolves hook-based confirmations via
_resolve_hook_confirmation - Server hook now emits SSE events and blocks until client responds via HTTP endpoint
- Context vars (
current_conversation_id,current_session_id) provide session context to hooks
Phase 5 Reversion Notes: The Phase 5 "tool migration" was reverted because:
- It added ~88 lines without removing any (violated simplification goal)
- Tools were creating ToolUse objects just to pass to confirmation - this is redundant since ToolUse already exists at the
ToolUse.execute()level - The
_execute_with_hook_confirmation()helper duplicated logic fromexecute_with_confirmation()
The hook system works correctly through the confirm_func bridge without requiring tools to create ToolUse objects.
Architecture notes:
- V1 API: Uses hook-aware confirm_func, auto-confirms when no context vars set (legacy behavior)
- V2 API: Uses separate
pending_toolsmechanism + hook resolution for HTTP confirmations - CLI: Uses hook-aware confirm_func, routes through cli_confirm_hook when registered
- Hooks receive confirmation requests via
make_confirm_func_from_hooks()bridge
Phase 6.1 Notes (Completed):
Consolidated duplicate _print_preview in cli_confirm.py by importing shared print_preview
from ask_execute.py. This reduced cli_confirm.py by 10 lines (245 → 235) and eliminates
duplicate preview logic.
Phase 6.4 Notes (Completed):
Extracted shared print_confirmation_help() function in ask_execute.py. Both
ask_execute and cli_confirm_hook now use this shared function instead of
maintaining duplicate help text. Reduced cli_confirm.py by 19 lines.
Phase 6.2-6.3 Notes (Completed):
Centralized auto-confirm state in confirm.py with unified functions:
set_auto_confirm(count)- Set auto-confirm (count or infinite)reset_auto_confirm()- Reset to defaultscheck_auto_confirm()- Check and decrement (returns tuple)is_auto_confirm_active()- Check without decrementing
Both cli_confirm.py and ask_execute.py now use this centralized state instead of
maintaining their own duplicate globals. Server auto-confirm is also unified -
server_confirm_hook now checks centralized state first before checking session context.
Phase 7 Notes (Completed): Removed ask_execute fallback from chat.py per Erik's suggestion:
- confirm_func now always uses hooks via make_confirm_func_from_hooks()
- CLI hook registration moved into init_hooks() via hook_allowlist parameter
- Auto-confirm state converted to ContextVars for thread safety in server mode
- When no_confirm=True, no CLI hook is registered, so get_confirmation() auto-confirms
Phase 8 Notes (Completed): Tools register their own auto-approve hooks via ToolSpec.hooks per Erik's suggestion:
- Modified
get_confirmation()to support fall-through: hooks returning None pass to next hook - Hooks are tried in priority order (highest first), first non-None result wins
- Updated
ToolConfirmHookprotocol: now returnsConfirmationResult | None - Shell tool registers
shell_allowlist_hookwith priority 10 (higher than CLI hook at 0) - Shell allowlist hook auto-confirms allowlisted commands, returns None for others
- This keeps ToolSpec clean (no new fields) while enabling tool-specific auto-approve
- Tests added: 3 fall-through tests + 5 shell allowlist tests (27 total passing)
Example usage for other tools:
def my_tool_auto_approve(tool_use, preview=None, workspace=None):
"""Auto-approve safe operations, fall through for others."""
if is_safe(tool_use):
return ConfirmationResult.confirm()
return None # Fall through to CLI/server hook
tool = ToolSpec(
name="my_tool",
hooks={
"auto_approve": ("tool_confirm", my_tool_auto_approve, 10),
},
...
)
Next steps:
- ✅ Phase 6.1-6.4: Consolidation complete
- ✅ Phase 7: Remove ask_execute fallback
- ✅ Phase 8: Tool auto-approve via ToolSpec hooks
- Phase 6.5: Document hook API for custom confirmation backends
- Phase 6.6: Add examples for new backends (GUI, Discord bot)
- Future: Consider moving confirmation to ToolUse.execute()
Problem Statement
gptme currently has two separate implementations for tool confirmation:
- CLI (
ask_execute.py): Interactive terminal-based confirmation with rich features - Server V2 (
api_v2_sessions.py): SSE event-based confirmation with pending tool queue
These implementations are not harmonized:
- CLI uses
ask_execute()called directly from tools viaConfirmFunc - Server V2 uses
pending_toolsdict withToolExecutionstate machine - Server V1 has no real confirmation support (always auto-confirms)
- No shared abstraction for confirmation logic
Goals
- Harmonize CLI and Server confirmation implementations
- Leverage hooks for extensibility and clean separation
- Maintain existing functionality (edit, copy, auto-confirm)
- Simplify tool implementations by removing confirmation boilerplate
- Enable new confirmation backends (e.g., GUI, Discord bot)
Current Architecture
CLI Flow
User/Tool → execute_with_confirmation() → ask_execute() → User Input → Execute
↓
print_preview()
↓
editable/copiable state
Server V2 Flow
Tool Execute → Store in pending_tools → SSE Event (tool_pending) → Client Decides
↓
/api/v2/.../tool/confirm
↓
Execute or Skip
Key Differences
| Aspect | CLI | Server V2 |
|---|---|---|
| Blocking | Synchronous (blocks thread) | Async (event-based) |
| Input | Terminal prompt | HTTP endpoint |
| Features | edit, copy, auto | confirm, edit, skip, auto |
| State | Global variables | Session object |
| Notification | Bell sound | SSE event |
Proposed Design
Core Concept: tool.confirm Hook
Introduce a new hook type tool.confirm that handles the confirmation decision:
class HookType(str, Enum):
# Existing hooks
TOOL_EXECUTE_PRE = "tool.execute.pre"
TOOL_EXECUTE_POST = "tool.execute.post"
# New confirmation hook
TOOL_CONFIRM = "tool.confirm"
Confirmation Protocol
The tool.confirm hook follows a request-response protocol:
- Request Phase: System triggers
tool.confirmwith tool details - Decision Phase: Hook implementation gathers user/client decision
- Response Phase: Hook yields a
ConfirmationResult
from dataclasses import dataclass
from typing import Literal
@dataclass
class ConfirmationResult:
"""Result of a tool confirmation request."""
action: Literal["confirm", "skip", "edit"]
edited_content: str | None = None
auto_confirm_remaining: int = 0
Hook Protocol
from typing import Protocol, Generator
from pathlib import Path
class ToolConfirmHook(Protocol):
"""Hook for tool confirmation decisions."""
def __call__(
self,
tooluse: "ToolUse",
preview: str | None,
workspace: Path | None,
) -> Generator[ConfirmationResult, None, None]:
"""Request confirmation for tool execution.
Args:
tooluse: The tool about to be executed
preview: Optional preview content for display
workspace: Workspace directory path
Yields:
ConfirmationResult with the user's decision
"""
pass
Implementation Architecture
┌─────────────────────────────────────┐
│ ToolUse.execute() │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ trigger_hook(TOOL_CONFIRM) │
└─────────────────┬───────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ CLI Confirm Hook│ │Server Confirm │ │ Auto Confirm │
│ (terminal input)│ │Hook (SSE/HTTP) │ │ Hook (always) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ ConfirmationResult │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Execute or Skip Tool │
└─────────────────────────────────────┘
Hook Implementations
1. CLI Confirmation Hook
def cli_confirm_hook(
tooluse: ToolUse,
preview: str | None,
workspace: Path | None,
) -> Generator[ConfirmationResult, None, None]:
"""CLI-based confirmation using terminal input."""
# Show preview if available
if preview:
print_preview(preview, tooluse.tool, copy=True)
# Make content editable
if tooluse.content:
set_editable_text(tooluse.content, get_extension(tooluse))
# Get user decision via terminal prompt
confirmed = ask_execute(f"Execute {tooluse.tool}?")
if confirmed:
edited = get_editable_text() if editable else None
was_edited = edited != tooluse.content if edited else False
yield ConfirmationResult(
action="edit" if was_edited else "confirm",
edited_content=edited if was_edited else None,
)
else:
yield ConfirmationResult(action="skip")
2. Server Confirmation Hook
def server_confirm_hook(
tooluse: ToolUse,
preview: str | None,
workspace: Path | None,
) -> Generator[ConfirmationResult, None, None]:
"""Server-based confirmation using SSE events."""
session = get_current_session()
# Check auto-confirm
if session.auto_confirm_count > 0:
session.auto_confirm_count -= 1
yield ConfirmationResult(action="confirm")
return
# Create pending tool entry
tool_id = str(uuid.uuid4())
session.pending_tools[tool_id] = ToolExecution(
tooluse=tooluse,
status=ToolStatus.PENDING,
)
# Emit SSE event
emit_event("tool_pending", {
"tool_id": tool_id,
"tool": tooluse.tool,
"content": tooluse.content,
"preview": preview,
})
# Wait for client decision (via HTTP endpoint)
result = wait_for_confirmation(tool_id)
yield result
3. Auto-Confirm Hook (Non-Interactive Mode)
def auto_confirm_hook(
tooluse: ToolUse,
preview: str | None,
workspace: Path | None,
) -> Generator[ConfirmationResult, None, None]:
"""Always confirms - for non-interactive/autonomous mode."""
yield ConfirmationResult(action="confirm")
Integration Points
Tool Execution Flow
# In tools/base.py - ToolUse.execute()
def execute(self, confirm: ConfirmFunc) -> Generator[Message, None, None]:
# Trigger confirmation hook
confirm_results = list(trigger_hook(
HookType.TOOL_CONFIRM,
tooluse=self,
preview=self.get_preview(),
workspace=get_workspace(),
))
if not confirm_results:
# No confirmation hook registered - fall back to confirm function
if not confirm(f"Execute {self.tool}?"):
yield Message("system", "Aborted")
return
result = ConfirmationResult(action="confirm")
else:
result = confirm_results[0]
# Handle result
if result.action == "skip":
yield Message("system", "Operation skipped by user")
return
if result.action == "edit" and result.edited_content:
self.content = result.edited_content
# Proceed with execution
yield from self._do_execute()
Hook Registration
# In gptme/main.py or gptme/chat.py
def init_confirmation_hooks(interactive: bool, server_mode: bool):
"""Register appropriate confirmation hook based on mode."""
if server_mode:
register_hook(
name="server_confirm",
hook_type=HookType.TOOL_CONFIRM,
func=server_confirm_hook,
priority=100,
)
elif interactive:
register_hook(
name="cli_confirm",
hook_type=HookType.TOOL_CONFIRM,
func=cli_confirm_hook,
priority=100,
)
else:
register_hook(
name="auto_confirm",
hook_type=HookType.TOOL_CONFIRM,
func=auto_confirm_hook,
priority=100,
)
Evaluation Dimensions
Dimension 1: Code Simplification
Criteria: Does this reduce complexity in tool implementations?
| Score | Description |
|---|---|
| 1 | Increases complexity |
| 2 | No change |
| 3 | Minor simplification |
| 4 | Moderate simplification |
| 5 | Major simplification |
Current Assessment: 4/5
Rationale:
- Tools no longer need to handle confirmation logic directly
execute_with_confirmation()helper can be simplified or deprecated- Single point of confirmation logic vs scattered across tools
- Minor complexity added in hook registration
Dimension 2: Extensibility
Criteria: How easy is it to add new confirmation backends?
| Score | Description |
|---|---|
| 1 | Requires core changes |
| 2 | Complex integration |
| 3 | Moderate effort |
| 4 | Simple plugin |
| 5 | Trivial addition |
Current Assessment: 5/5
Rationale:
- New backends just register a hook function
- No core code changes needed
- Examples: Discord bot, GUI, mobile app, voice confirmation
- Clear protocol makes implementation straightforward
Dimension 3: Backward Compatibility
Criteria: Does this maintain existing behavior and APIs?
| Score | Description |
|---|---|
| 1 | Breaking changes, migration required |
| 2 | Breaking changes, partial migration |
| 3 | Deprecation warnings, works with changes |
| 4 | Fully backward compatible with deprecations |
| 5 | Fully backward compatible, no changes needed |
Current Assessment: 4/5
Rationale:
ConfirmFunctype can still work (fallback when no hook)ask_execute()still functions (wrapped by CLI hook)- Server V2 API unchanged externally
- Internal refactoring required for
execute_with_confirmation()
Dimension 4: Testability
Criteria: How testable is the new design?
| Score | Description |
|---|---|
| 1 | Untestable / requires manual testing |
| 2 | Difficult to test |
| 3 | Moderate test effort |
| 4 | Easy to unit test |
| 5 | Excellent testability with mocks |
Current Assessment: 5/5
Rationale:
- Hooks are pure functions that can be mocked
ConfirmationResultis a simple dataclass- Can test each hook implementation independently
- Can test tool execution with different hook configurations
Dimension 5: Server Harmonization
Criteria: Does this improve CLI/Server code sharing?
| Score | Description |
|---|---|
| 1 | More divergence |
| 2 | No change |
| 3 | Minor sharing |
| 4 | Significant sharing |
| 5 | Full harmonization |
Current Assessment: 4/5
Rationale:
- Same protocol for both CLI and Server
- Same
ConfirmationResulttype - Tool code doesn't need to know which environment
- Server still needs SSE/HTTP infrastructure (inherent)
Dimension 6: Performance Impact
Criteria: Does this affect performance?
| Score | Description |
|---|---|
| 1 | Significant slowdown |
| 2 | Noticeable slowdown |
| 3 | Minor impact |
| 4 | Negligible impact |
| 5 | No impact or improvement |
Current Assessment: 5/5
Rationale:
- Hook dispatch is O(1) lookup
- No additional I/O or computation
- Existing confirmation logic just moves to hook
- Could potentially improve by reducing redundant preview generation
Overall Evaluation
| Dimension | Score | Weight | Weighted |
|---|---|---|---|
| Code Simplification | 4 | 20% | 0.80 |
| Extensibility | 5 | 25% | 1.25 |
| Backward Compatibility | 4 | 20% | 0.80 |
| Testability | 5 | 15% | 0.75 |
| Server Harmonization | 4 | 15% | 0.60 |
| Performance Impact | 5 | 5% | 0.25 |
| Total | 4.45/5 |
Implementation Plan
Phase 1: Foundation (1-2 days)
- Add
HookType.TOOL_CONFIRMenum value - Add
ToolConfirmHookprotocol - Add
ConfirmationResultdataclass - Update hook type overloads
Phase 2: CLI Implementation (2-3 days)
- Create
gptme/hooks/cli_confirm.py - Refactor
ask_execute.pyto be callable by hook - Register CLI hook in interactive mode
- Test with existing CLI flows
Phase 3: Server Implementation (2-3 days)
- Create
gptme/hooks/server_confirm.py - Integrate with
api_v2_sessions.pypending_tools - Register Server hook in server mode
- Test with existing Server V2 flows
Phase 4: Tool Migration (3-5 days)
- Update
ToolUse.execute()to use confirmation hook - Simplify
execute_with_confirmation()usage - Migrate tools one by one
- Add deprecation warnings for direct
ask_executeusage
Phase 5: Documentation & Cleanup (1-2 days)
- Document hook API
- Add examples for custom confirmation backends
- Remove deprecated code paths
- Update tests
Risks and Mitigations
Risk 1: Async/Sync Mismatch
Risk: Server needs async, CLI is sync Mitigation: Hook protocol uses generators which work for both; Server hook uses threading.Event for blocking
Risk 2: State Management
Risk: Auto-confirm count, editable state are currently global Mitigation: Move state into hook context or use ContextVars
Risk 3: Migration Complexity
Risk: Many tools use execute_with_confirmation
Mitigation: Phased migration with backward compatibility layer
Open Questions
- Priority System: Should multiple confirmation hooks be allowed? (e.g., logging + confirmation)
- Timeout: Should there be a configurable timeout for confirmation?
- Preview Protocol: Should preview generation be standardized across tools?
- State Location: Where should auto-confirm count live in server mode?
Alternatives Considered
Alternative 1: Use Existing TOOL_EXECUTE_PRE
Rejected: PRE hook doesn't have a response mechanism; would need to modify hook system fundamentally.
Alternative 2: Middleware Pattern
Rejected: More complex than hooks; would require new abstraction layer.
Alternative 3: Event System
Rejected: Overkill for this use case; hooks are simpler and already exist.
Conclusion
The hook-based confirmation design provides a clean, extensible solution that:
- Harmonizes CLI and Server implementations
- Maintains backward compatibility
- Enables new confirmation backends
- Simplifies tool implementations
Recommendation: Proceed with implementation starting from Phase 1.