diff --git a/README.md b/README.md index d7d9d0e..925714a 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,7 @@ uv run main.py --permissions gmail:send drive:full --tool-tier core Granular permissions mode provides service-by-service scope control: - Format: `service:level` (one entry per service) - Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative) +- Tasks levels: `readonly`, `manage`, `full` (cumulative; `manage` allows create/update/move but denies `delete` and `clear_completed`) - Other services currently support: `readonly`, `full` - `--permissions` and `--read-only` are mutually exclusive - `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`) diff --git a/auth/permissions.py b/auth/permissions.py index caa38d0..1a5b419 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -9,11 +9,12 @@ Usage: --permissions gmail:organize drive:readonly Gmail levels: readonly, organize, drafts, send, full +Tasks levels: readonly, manage, full Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS) """ import logging -from typing import Dict, List, Optional, Tuple +from typing import Dict, FrozenSet, List, Optional, Set, Tuple from auth.scopes import ( GMAIL_READONLY_SCOPE, @@ -97,7 +98,8 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { ], "tasks": [ ("readonly", [TASKS_READONLY_SCOPE]), - ("full", [TASKS_SCOPE]), + ("manage", [TASKS_SCOPE]), + ("full", []), ], "contacts": [ ("readonly", [CONTACTS_READONLY_SCOPE]), @@ -131,16 +133,43 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { ], } +# Actions denied at specific permission levels. +# Maps service -> level -> frozenset of denied action names. +# Levels not listed here (or services without entries) deny nothing. +SERVICE_DENIED_ACTIONS: Dict[str, Dict[str, FrozenSet[str]]] = { + "tasks": { + "manage": frozenset({"delete", "clear_completed"}), + }, +} + + +def is_action_denied(service: str, action: str) -> bool: + """Check whether *action* is denied for *service* under current permissions. + + Returns ``False`` when granular permissions mode is not active, when the + service has no permission entry, or when the configured level does not + deny the action. + """ + if _PERMISSIONS is None: + return False + level = _PERMISSIONS.get(service) + if level is None: + return False + denied = SERVICE_DENIED_ACTIONS.get(service, {}).get(level, frozenset()) + return action in denied + + # Module-level state: parsed --permissions config # Dict mapping service_name -> level_name, e.g. {"gmail": "organize"} _PERMISSIONS: Optional[Dict[str, str]] = None -def set_permissions(permissions: Dict[str, str]) -> None: +def set_permissions(permissions: Optional[Dict[str, str]]) -> None: """Set granular permissions from parsed --permissions argument.""" global _PERMISSIONS _PERMISSIONS = permissions - logger.info("Granular permissions set: %s", permissions) + if permissions is not None: + logger.info("Granular permissions set: %s", permissions) def get_permissions() -> Optional[Dict[str, str]]: diff --git a/gtasks/tasks_tools.py b/gtasks/tasks_tools.py index 482e517..0e9cff3 100644 --- a/gtasks/tasks_tools.py +++ b/gtasks/tasks_tools.py @@ -13,6 +13,7 @@ from googleapiclient.errors import HttpError # type: ignore from mcp import Resource from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider +from auth.permissions import is_action_denied from auth.service_decorator import require_google_service from core.server import server from core.utils import UserInputError, handle_http_errors @@ -320,6 +321,11 @@ async def manage_task_list( f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" ) + if is_action_denied("tasks", action): + raise UserInputError( + f"The '{action}' action is not allowed under the current permission level." + ) + if action == "create": if not title: raise UserInputError("'title' is required for the 'create' action.") @@ -887,6 +893,11 @@ async def manage_task( f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" ) + if is_action_denied("tasks", action): + raise UserInputError( + f"The '{action}' action is not allowed under the current permission level." + ) + if action == "create": if status is not None: raise UserInputError("'status' is only supported for the 'update' action.") diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 2d6c7d1..66f5d62 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -14,7 +14,9 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..") from auth.permissions import ( get_scopes_for_permission, + is_action_denied, parse_permissions_arg, + set_permissions, SERVICE_PERMISSION_LEVELS, ) from auth.scopes import ( @@ -24,6 +26,8 @@ from auth.scopes import ( GMAIL_COMPOSE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_SCOPE, + TASKS_READONLY_SCOPE, + TASKS_SCOPE, DRIVE_FILE_SCOPE, ) @@ -68,6 +72,11 @@ class TestParsePermissionsArg: with pytest.raises(ValueError, match="Unknown level"): parse_permissions_arg(["gmail:read:only"]) + def test_tasks_manage_is_valid_level(self): + """tasks:manage should be accepted by parse_permissions_arg.""" + result = parse_permissions_arg(["tasks:manage"]) + assert result == {"tasks": "manage"} + class TestGetScopesForPermission: """Tests for get_scopes_for_permission() cumulative scope expansion.""" @@ -116,3 +125,77 @@ class TestGetScopesForPermission: assert len(scopes) == len(set(scopes)), ( f"Duplicate scopes for {service}:{level_name}" ) + + def test_tasks_manage_includes_write_scope(self): + """Manage level should cumulatively include readonly and write scopes.""" + scopes = get_scopes_for_permission("tasks", "manage") + assert TASKS_SCOPE in scopes + assert TASKS_READONLY_SCOPE in scopes + + def test_tasks_full_includes_write_scope(self): + """Full level should include write and readonly scopes from lower levels.""" + scopes = get_scopes_for_permission("tasks", "full") + assert TASKS_SCOPE in scopes + assert TASKS_READONLY_SCOPE in scopes + + +@pytest.fixture(autouse=True) +def _reset_permissions_state(): + """Ensure each test starts and ends with no active permissions.""" + set_permissions(None) + yield + set_permissions(None) + + +class TestIsActionDenied: + """Tests for is_action_denied() and SERVICE_DENIED_ACTIONS.""" + + def test_no_permissions_mode_allows_all(self): + """Without granular permissions, no action is denied.""" + set_permissions(None) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_full_allows_delete(self): + """Full level should not deny delete.""" + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_manage_denies_delete(self): + """Manage level should deny delete.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "delete") is True + + def test_tasks_manage_allows_create(self): + """Manage level should allow create.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "create") is False + + def test_tasks_manage_allows_update(self): + """Manage level should allow update.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "update") is False + + def test_tasks_manage_allows_move(self): + """Manage level should allow move.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "move") is False + + def test_tasks_manage_denies_clear_completed(self): + """Manage level should deny clear_completed.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "clear_completed") is True + + def test_tasks_full_allows_clear_completed(self): + """Full level should not deny clear_completed.""" + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "clear_completed") is False + + def test_service_not_in_permissions_allows_all(self): + """A service not listed in permissions should allow all actions.""" + set_permissions({"gmail": "readonly"}) + assert is_action_denied("tasks", "delete") is False + + def test_service_without_denied_actions_allows_all(self): + """A service with no SERVICE_DENIED_ACTIONS entry should allow all actions.""" + set_permissions({"gmail": "readonly"}) + assert is_action_denied("gmail", "delete") is False