Skip to content

feat: Permission system for client tool calls#3370

Draft
BinaryMuse wants to merge 14 commits intomainfrom
mkt/tool-perms
Draft

feat: Permission system for client tool calls#3370
BinaryMuse wants to merge 14 commits intomainfrom
mkt/tool-perms

Conversation

@BinaryMuse
Copy link
Copy Markdown
Member

@BinaryMuse BinaryMuse commented Mar 31, 2026

This PR implements a permission system for client-side tool calls. It uses a model similar to Claude Code:

  • Rules are a tool name with an optional scope:
    • Read
    • Write(src/**/*)
    • Shell(git commit *)
  • Global permissions are stored as a sibling of config.toml
  • Project/directory specific permissions live in .atuin/permissions.ai.toml
  • Rules from files deeper in the filesystem tree override rules from a shallower directory

…oundary

This change extracts four new abstractions and sets a clear architectural
rule that will guide subsequent refactoring:

**Mutation boundary (Phase 1a):** AppState no longer owns an
mpsc::Sender. State is passive data; the event loop (or stream task) is
the only thing that emits events. handle_client_tool_call is now a pure
mutation — the CheckToolCallPermission event is sent by the caller.

**AppContext (Phase 1b):** Session-scoped API configuration (endpoint,
token, send_cwd) is now a single Clone struct instead of three
individually-cloned bindings in the event loop.

**ClientContext + ChatRequest (Phase 1c):** Machine identity (OS, shell,
distro) is computed once per session via ClientContext::detect() instead
of on every SSE request. ChatRequest wraps the per-turn message/session
payload, replacing the inline request body construction in
create_chat_stream.

**ToolDescriptor (Phase 1d):** Centralizes tool metadata — canonical
names, display verbs, progressive/past verbs, and client/server
classification — into static descriptors. Replaces four separate
name-to-text match sites and fixes a bug where is_client was checked
against 'file_read' (wrong) instead of 'read_file' (correct).

Also fixes several clippy warnings in modified code.
…rames

**State decomposition (Phase 2a):** AppState is replaced by three types
with clear ownership:

- Conversation: owns the event log and session_id. All pure query
  and event-manipulation methods (events_to_messages, current_command,
  has_any_command, etc.) move here.
- Interaction: owns ephemeral UI state (mode, is_input_blank,
  confirmation_pending, streaming_status, error).
- Session: the top-level type containing conversation, interaction,
  pending_tool_calls, exit_action, and stream_abort. Cross-cutting
  lifecycle methods (start_streaming, cancel_streaming, add_tool_call,
  etc.) stay here.

**Dispatch extraction (Phase 2b):** The 400-line match event in the
spawn_blocking loop is now a 5-line dispatch call. All 12 handlers are
named functions in a new tui/dispatch.rs module. inline.rs shrinks
from ~640 lines to ~240 lines.

**Stream launch centralization (Phase 2c):** The duplicated 8-line
stream launch ritual (present in ContinueAfterTools, SubmitInput, and
Retry) is replaced by a single launch_stream function that takes a
setup callback for pre-work. Each handler collapses to one line.

**Stream frame split (Phase 2d):** ChatStreamEvent is replaced by
StreamFrame with explicit Content (TextChunk, ToolCall, ToolResult)
and Control (Done, Error, StatusChanged) variants. run_chat_stream
now dispatches on frame type, with apply_content_frame and
apply_control_frame as separate functions.
Extracts three abstractions that untangle the ~180-line
on_check_tool_permission handler:

**PermissionResolver (permissions/resolver.rs):** Composes the
PermissionWalker + PermissionChecker into a single new/check API.
The handler no longer imports Walker, Checker, or Request directly.

**ToolOutcome + ClientToolCall::execute (tools/mod.rs):** Each tool
variant owns its execution logic. ReadToolCall::execute handles both
directory listing and file reading (previously only directory listing
worked — file reading was a no-op). Returns ToolOutcome::Success or
ToolOutcome::Error, replacing the inline match arms.

**PendingToolCall state transitions (tools/mod.rs):** mark_asking(),
mark_executing(), mark_denied() methods formalize the state machine
instead of direct enum variant assignment.

**Session::complete_tool_call (tui/state.rs):** Combines
add_tool_result + pending_tool_calls.retain into one method,
replacing the repeated cleanup pattern in the handler.

The handler drops from ~180 lines to ~60 lines.
Add the history database to AppContext and plumb it through dispatch
so client tools can run async database queries. AtuinHistory::execute
uses atuin-client's Database::search with fuzzy matching, the first
filter mode from the tool call, and configurable limit.

Also fix two pre-existing bugs that prevented client tools from
working end-to-end: AtuinHistory::matches_rule had a todo!() panic
that crashed the permission check task, and on_select_permission
Allow was discarding the tool call instead of executing it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant