Files
SneakyCode/app/services/permissions.py
Phillip Tarrant 638aecb561 feat: add custom HeaderPanel widget and switchable agent modes
Replace built-in Header with a custom HeaderPanel showing model name,
mode badge, and live token usage. Add AgentMode enum (normal/plan/auto)
with mode-aware permission gating — Plan mode restricts to read-only
tools, Auto mode auto-approves everything. Includes /mode slash command
and Ctrl+P keybinding to cycle modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:36:23 -05:00

154 lines
5.1 KiB
Python

"""Permission gating for tool execution."""
from __future__ import annotations
import json
import logging
import re
import shlex
from collections.abc import Awaitable, Callable
from app.models.config import AgentMode, PermissionsConfig, ToolsConfig
logger = logging.getLogger(__name__)
# Type alias for the async prompt callback
PromptCallback = Callable[[str, str], Awaitable[bool]]
# Detect shell redirects that write to files (>, >>, heredocs)
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
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.
In TUI mode, set a prompt callback via set_prompt_callback() that
shows a modal dialog. Without a callback, unlisted tools are denied.
"""
READ_ONLY_TOOLS: frozenset[str] = frozenset({
"read_file", "list_dir", "grep_files", "find_files", "finish",
})
def __init__(
self,
config: PermissionsConfig,
tools_config: ToolsConfig | None = None,
) -> None:
self.config = config
self._tools_config = tools_config
self._prompt_callback: PromptCallback | None = None
self._mode: AgentMode = AgentMode.NORMAL
@property
def mode(self) -> AgentMode:
"""Current agent mode."""
return self._mode
@mode.setter
def mode(self, value: AgentMode) -> None:
self._mode = value
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 = "",
arguments: str = "",
) -> bool:
"""Check if a tool is permitted to run.
Args:
tool_name: Name of the tool to check.
description: Human-readable description for the prompt.
arguments: Raw JSON arguments string (used for shell-aware checks).
Returns:
True if permitted, False if denied.
"""
if tool_name in self.config.deny:
logger.info("Tool '%s' is in deny list — blocked", tool_name)
return False
if self._mode == AgentMode.AUTO:
logger.debug("Tool '%s' auto-approved (AUTO mode)", tool_name)
return True
if self._mode == AgentMode.PLAN:
if tool_name not in self.READ_ONLY_TOOLS:
logger.info("Tool '%s' blocked in Plan mode (read-only tools only)", tool_name)
return False
return True
if tool_name in self.config.auto_approve:
logger.debug("Tool '%s' is auto-approved", tool_name)
return True
# Shell-aware: check allowed/denied command lists
if tool_name == "run_command" and self._tools_config is not None:
result = self._check_shell_command(arguments)
if result is not None:
return result
# Prompt user via callback (TUI modal, etc.)
if self._prompt_callback is not None:
return await self._prompt_callback(tool_name, description)
# No callback set — deny by default (safe fallback)
logger.warning("Tool '%s' requires approval but no prompt callback set — denied", tool_name)
return False
def _check_shell_command(self, arguments: str) -> bool | None:
"""Check shell command against allowed/denied lists.
Returns True (allow), False (deny), or None (fall through to prompt).
"""
shell_config = self._tools_config.shell # type: ignore[union-attr]
try:
cmd = json.loads(arguments).get("command", "")
except (json.JSONDecodeError, AttributeError):
return None # can't parse, fall through to prompt
try:
base_cmd = shlex.split(cmd)[0]
except (ValueError, IndexError):
return None
# Denied commands: prefix match on full command string
for denied in shell_config.denied_commands:
if cmd.startswith(denied):
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
return False
# Detect shell redirects that write to files — require approval
if _WRITE_REDIRECT_PATTERN.search(cmd):
logger.info("Shell command '%s' contains file-write redirect — requiring approval", cmd)
return None # fall through to user prompt
# Allowed commands: base executable match
if shell_config.allowed_commands:
if base_cmd in shell_config.allowed_commands:
logger.debug(
"Shell command '%s' auto-approved (base '%s' in allowed list)",
cmd,
base_cmd,
)
return True
# Base command NOT in allowed list — fall through to prompt
return None
# No allowed list configured — fall through to prompt
return None