Files
owlen/PERMISSION_SYSTEM.md

11 KiB

Permission System - TUI Implementation

Overview

The TUI now has a fully functional interactive permission system that allows users to grant, deny, or permanently allow tool executions through an elegant popup interface.

Features Implemented

1. Interactive Permission Popup

Location: crates/app/ui/src/components/permission_popup.rs

  • Visual Design:

    • Centered modal popup with themed border
    • Tool name highlighted with icon ()
    • Context information (file path, command, etc.)
    • Four selectable options with icons
    • Keyboard shortcuts and navigation hints
  • Options Available:

    • [a] Allow once - Execute this one time
    • ✓✓ [A] Always allow - Add permanent rule to permission manager
    • [d] Deny - Refuse this operation
    • ? [?] Explain - Show what this operation does
  • Navigation:

    • Arrow keys (↑/↓) to select options
    • Enter to confirm selection
    • Keyboard shortcuts (a/A/d/?) for quick selection
    • Esc to deny and close

2. Permission Flow Integration

Location: crates/app/ui/src/app.rs

New Components:

  1. PendingToolCall struct:

    struct PendingToolCall {
        tool_name: String,
        arguments: Value,
        perm_tool: PermTool,
        context: Option<String>,
    }
    

    Stores information about tool awaiting permission.

  2. TuiApp fields:

    • pending_tool: Option<PendingToolCall> - Current pending tool
    • permission_tx: Option<oneshot::Sender<bool>> - Channel to signal decision
  3. execute_tool_with_permission() method:

    async fn execute_tool_with_permission(
        &mut self,
        tool_name: &str,
        arguments: &Value,
    ) -> Result<String>
    

    Flow:

    1. Maps tool name to PermTool enum (Read, Write, Edit, Bash, etc.)
    2. Extracts context (file path, command, etc.)
    3. Checks permission via PermissionManager
    4. If Allow → Execute immediately
    5. If Deny → Return error
    6. If Ask → Show popup and wait for user decision

    Async Wait Mechanism:

    • Creates oneshot channel for permission response
    • Shows permission popup
    • Awaits channel response (with 5-minute timeout)
    • Event loop continues processing keyboard events
    • When user responds, channel signals and execution resumes

3. Permission Decision Handling

Location: crates/app/ui/src/app.rs:184-254

When user makes a choice in the popup:

  • Allow Once:

    • Signals permission granted (sends true through channel)
    • Tool executes once
    • No persistent changes
  • Always Allow:

    • Adds new rule to PermissionManager
    • Rule format: perms.add_rule(tool, context, Action::Allow)
    • Example: Always allow reading from src/ directory
    • Signals permission granted
    • All future matching operations auto-approved
  • Deny:

    • Signals permission denied (sends false)
    • Tool execution fails with error
    • Error shown in chat
  • Explain:

    • Shows explanation of what the tool does
    • Popup remains open for user to choose again
    • Tool-specific explanations:
      • read → "read a file from disk"
      • write → "write or overwrite a file"
      • edit → "modify an existing file"
      • bash → "execute a shell command"
      • grep → "search for patterns in files"
      • glob → "list files matching a pattern"

4. Agent Loop Integration

Location: crates/app/ui/src/app.rs:488

Changed from:

match execute_tool(tool_name, arguments, &self.perms).await {

To:

match self.execute_tool_with_permission(tool_name, arguments).await {

This ensures all tool calls in the streaming agent loop go through the permission system.

Architecture Details

Async Concurrency Model

The implementation uses Rust's async/await with tokio to handle the permission flow without blocking the UI:

┌─────────────────────────────────────────────────────────┐
│                     Event Loop                          │
│  (continuously running at 60 FPS)                       │
│                                                          │
│  while running {                                        │
│    terminal.draw(...)  ← Always responsive              │
│    if let Ok(event) = event_rx.try_recv() {            │
│      handle_event(event).await                          │
│    }                                                     │
│    tokio::sleep(16ms).await  ← Yields to runtime       │
│  }                                                       │
└─────────────────────────────────────────────────────────┘
                    ↕
┌─────────────────────────────────────────────────────────┐
│              Keyboard Event Listener                    │
│  (separate tokio task)                                  │
│                                                          │
│  loop {                                                 │
│    event = event_stream.next().await                    │
│    event_tx.send(Input(key))                            │
│  }                                                       │
└─────────────────────────────────────────────────────────┘
                    ↕
┌─────────────────────────────────────────────────────────┐
│          Permission Request Flow                        │
│                                                          │
│  1. Tool needs permission (PermissionDecision::Ask)     │
│  2. Create oneshot channel (tx, rx)                     │
│  3. Show popup, store tx                                │
│  4. await rx  ← Yields to event loop                    │
│  5. Event loop continues, handles keyboard              │
│  6. User presses 'a' → handle_event processes           │
│  7. tx.send(true) signals channel                       │
│  8. rx.await completes, returns true                    │
│  9. Tool executes with permission                       │
└─────────────────────────────────────────────────────────┘

Key Insight

The implementation works because:

  1. Awaiting is non-blocking: When we await rx, we yield control to the tokio runtime
  2. Event loop continues: The outer event loop continues to run its iterations
  3. Keyboard events processed: The separate event listener task continues reading keyboard
  4. Channel signals resume: When user responds, the channel completes and we resume

This creates a smooth UX where the UI remains responsive while waiting for permission.

Usage Examples

Example 1: First-time File Write

User: "Create a new file hello.txt with 'Hello World'"

Agent: [Calls write tool]

┌───────────────────────────────────────┐
│ 🔒 Permission Required                │
├───────────────────────────────────────┤
│ ⚡ Tool: write                         │
│ 📝 Context:                           │
│    hello.txt                          │
├───────────────────────────────────────┤
│ ▶ ✓ [a] Allow once                    │
│   ✓✓ [A] Always allow                 │
│   ✗ [d] Deny                          │
│   ? [?] Explain                       │
│                                       │
│ ↑↓ Navigate  Enter to select  Esc... │
└───────────────────────────────────────┘

User presses 'a' → File created once

Example 2: Always Allow Bash in Current Directory

User: "Run npm test"

Agent: [Calls bash tool]

[Permission popup shows with context: "npm test"]

User presses 'A' → Rule added: bash("npm test*") → Allow

Future: User: "Run npm test:unit"
        Agent: [Executes immediately, no popup]

Example 3: Explanation Request

User: "Read my secrets.env file"

[Permission popup appears]

User presses '?' →

System: "Tool 'read' requires permission. This operation
        will read a file from disk."

[Popup remains open]

User presses 'd' → Permission denied

Testing

Build status: All tests pass

cargo build --workspace  # Success
cargo test --workspace --lib  # 28 tests passed

Configuration

The permission system respects three modes from PermissionManager:

  1. Plan Mode (default):

    • Read operations (read, grep, glob) → Auto-allowed
    • Write operations (write, edit) → Ask
    • System operations (bash) → Ask
  2. AcceptEdits Mode:

    • Read operations → Auto-allowed
    • Write operations → Auto-allowed
    • System operations (bash) → Ask
  3. Code Mode:

    • All operations → Auto-allowed
    • No popups shown

User can override mode with CLI flag: --mode code

Future Enhancements

Potential improvements:

  1. Permission History:

    • Show recently granted/denied permissions
    • /permissions command to view active rules
  2. Temporary Rules:

    • "Allow for this session" option
    • Rules expire when TUI closes
  3. Pattern-based Rules:

    • "Always allow reading from src/ directory"
    • "Always allow bash commands starting with npm"
  4. Visual Feedback:

    • Show indicator when permission auto-granted by rule
    • Different styling for policy-denied vs user-denied
  5. Rule Management:

    • /clear-rules command
    • Edit/remove specific rules interactively

Files Modified

  • crates/app/ui/src/app.rs - Main permission flow logic
  • crates/app/ui/src/events.rs - Removed unused event type
  • crates/app/ui/src/components/permission_popup.rs - Pre-existing, now fully integrated

Summary

The TUI permission system is now fully functional, providing:

  • Interactive permission popups with keyboard navigation
  • Four permission options (allow once, always, deny, explain)
  • Runtime permission rule updates
  • Async flow that keeps UI responsive
  • Integration with existing permission manager
  • Tool-specific context and explanations
  • Timeout handling (5 minutes)
  • All tests passing

Users can now safely interact with the AI agent while maintaining control over potentially dangerous operations.