From 377791080c0e600dfb9c8faaf312e9e003a938d9 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:04:31 +1100 Subject: [PATCH] feat: add tasks:manage permission level to deny delete without blocking other writes The consolidated manage_task tool bundles create/update/delete/move into a single tool, making it impossible to deny just the delete action via tool tiers or scope-based filtering. This adds: - A `manage` permission level for tasks (between readonly and full) - A SERVICE_DENIED_ACTIONS registry mapping (service, level) to denied actions - An is_action_denied() helper that tools call before executing actions - Guards in manage_task and manage_task_list that reject denied actions Usage: --permissions tasks:manage Allows create, update, move. Denies delete. tasks:full remains unchanged (all actions allowed). Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + auth/permissions.py | 37 ++++++++++++++++++++++++--- gtasks/tasks_tools.py | 11 ++++++++ tests/test_permissions.py | 54 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7740390..ba09d70 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,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) - 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..abd0686 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"}), + }, +} + + +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 cc6d6bf..0f2d3f3 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 @@ -314,6 +315,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.") @@ -881,6 +887,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..764b49f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -14,7 +14,10 @@ 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_DENIED_ACTIONS, SERVICE_PERMISSION_LEVELS, ) from auth.scopes import ( @@ -24,6 +27,8 @@ from auth.scopes import ( GMAIL_COMPOSE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_SCOPE, + TASKS_READONLY_SCOPE, + TASKS_SCOPE, DRIVE_FILE_SCOPE, ) @@ -116,3 +121,52 @@ class TestGetScopesForPermission: assert len(scopes) == len(set(scopes)), ( f"Duplicate scopes for {service}:{level_name}" ) + + def test_tasks_manage_includes_write_scope(self): + 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): + scopes = get_scopes_for_permission("tasks", "full") + assert TASKS_SCOPE in scopes + + def test_tasks_manage_is_valid_level(self): + result = parse_permissions_arg(["tasks:manage"]) + assert result == {"tasks": "manage"} + + +class TestIsActionDenied: + """Tests for is_action_denied() and SERVICE_DENIED_ACTIONS.""" + + def test_no_permissions_mode_allows_all(self): + set_permissions(None) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_full_allows_delete(self): + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_manage_denies_delete(self): + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "delete") is True + + def test_tasks_manage_allows_create(self): + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "create") is False + + def test_tasks_manage_allows_update(self): + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "update") is False + + def test_tasks_manage_allows_move(self): + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "move") is False + + def test_service_not_in_permissions_allows_all(self): + set_permissions({"gmail": "readonly"}) + assert is_action_denied("tasks", "delete") is False + + def test_service_without_denied_actions_allows_all(self): + set_permissions({"gmail": "readonly"}) + assert is_action_denied("gmail", "delete") is False