feat: make PermissionsService async with pluggable prompt callback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,42 @@
|
||||
"""Permission gating for tool execution."""
|
||||
|
||||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.prompt import Confirm
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from app.models.config import PermissionsConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type alias for the async prompt callback
|
||||
PromptCallback = Callable[[str, str], Awaitable[bool]]
|
||||
|
||||
|
||||
class PermissionDenied(Exception):
|
||||
"""Raised when a tool is denied execution by permissions policy."""
|
||||
|
||||
|
||||
class PermissionsService:
|
||||
"""Check whether a tool is allowed to execute based on config tiers."""
|
||||
"""Check whether a tool is allowed to execute based on config tiers.
|
||||
|
||||
In TUI mode, set a prompt callback via set_prompt_callback() that
|
||||
shows a modal dialog. Without a callback, unlisted tools are denied.
|
||||
"""
|
||||
|
||||
def __init__(self, config: PermissionsConfig) -> None:
|
||||
self.config = config
|
||||
self._prompt_callback: PromptCallback | None = None
|
||||
|
||||
def check(self, tool_name: str, description: str = "") -> bool:
|
||||
def set_prompt_callback(self, callback: PromptCallback) -> None:
|
||||
"""Set the async callback used to prompt the user for permission.
|
||||
|
||||
Args:
|
||||
callback: Async function(tool_name, description) -> bool.
|
||||
"""
|
||||
self._prompt_callback = callback
|
||||
|
||||
async def check(self, tool_name: str, description: str = "") -> bool:
|
||||
"""Check if a tool is permitted to run.
|
||||
|
||||
Returns:
|
||||
@@ -33,14 +50,10 @@ class PermissionsService:
|
||||
logger.debug("Tool '%s' is auto-approved", tool_name)
|
||||
return True
|
||||
|
||||
# Explicit prompt_user list or unlisted tools both trigger a prompt
|
||||
return self._prompt_user(tool_name, description)
|
||||
# Prompt user via callback (TUI modal, etc.)
|
||||
if self._prompt_callback is not None:
|
||||
return await self._prompt_callback(tool_name, description)
|
||||
|
||||
def _prompt_user(self, tool_name: str, description: str) -> bool:
|
||||
"""Prompt the user for approval via the terminal."""
|
||||
prompt_text = f"Allow tool [bold]{tool_name}[/bold]"
|
||||
if description:
|
||||
prompt_text += f" — {description}"
|
||||
prompt_text += "?"
|
||||
|
||||
return Confirm.ask(prompt_text, default=False)
|
||||
# No callback set — deny by default (safe fallback)
|
||||
logger.warning("Tool '%s' requires approval but no prompt callback set — denied", tool_name)
|
||||
return False
|
||||
|
||||
51
tests/unit/test_permissions.py
Normal file
51
tests/unit/test_permissions.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tests for async PermissionsService."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.config import PermissionsConfig
|
||||
from app.services.permissions import PermissionsService
|
||||
|
||||
|
||||
class TestPermissionsService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_approve(self) -> None:
|
||||
config = PermissionsConfig(auto_approve=["read_file"])
|
||||
svc = PermissionsService(config)
|
||||
assert await svc.check("read_file") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deny(self) -> None:
|
||||
config = PermissionsConfig(deny=["rm_file"])
|
||||
svc = PermissionsService(config)
|
||||
assert await svc.check("rm_file") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_callback_approve(self) -> None:
|
||||
config = PermissionsConfig()
|
||||
svc = PermissionsService(config)
|
||||
|
||||
async def approve_callback(tool_name: str, description: str) -> bool:
|
||||
return True
|
||||
|
||||
svc.set_prompt_callback(approve_callback)
|
||||
assert await svc.check("write_file", description="write something") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_callback_deny(self) -> None:
|
||||
config = PermissionsConfig()
|
||||
svc = PermissionsService(config)
|
||||
|
||||
async def deny_callback(tool_name: str, description: str) -> bool:
|
||||
return False
|
||||
|
||||
svc.set_prompt_callback(deny_callback)
|
||||
assert await svc.check("write_file") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_callback_defaults_to_deny(self) -> None:
|
||||
"""Without a callback set, unlisted tools are denied."""
|
||||
config = PermissionsConfig()
|
||||
svc = PermissionsService(config)
|
||||
assert await svc.check("write_file") is False
|
||||
Reference in New Issue
Block a user