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 1/6] 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 From 0fce7c78b62aeb69bbdf1e8f1a6dbf44c0327704 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:21:51 +1100 Subject: [PATCH 2/6] fix: also deny clear_completed under tasks:manage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review — clear_completed is destructive and should be blocked alongside delete at the manage permission level. Co-Authored-By: Claude Opus 4.6 --- auth/permissions.py | 2 +- tests/test_permissions.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/auth/permissions.py b/auth/permissions.py index abd0686..1a5b419 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -138,7 +138,7 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { # Levels not listed here (or services without entries) deny nothing. SERVICE_DENIED_ACTIONS: Dict[str, Dict[str, FrozenSet[str]]] = { "tasks": { - "manage": frozenset({"delete"}), + "manage": frozenset({"delete", "clear_completed"}), }, } diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 764b49f..c1877f8 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -163,6 +163,14 @@ class TestIsActionDenied: set_permissions({"tasks": "manage"}) assert is_action_denied("tasks", "move") is False + def test_tasks_manage_denies_clear_completed(self): + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "clear_completed") is True + + def test_tasks_full_allows_clear_completed(self): + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "clear_completed") is False + def test_service_not_in_permissions_allows_all(self): set_permissions({"gmail": "readonly"}) assert is_action_denied("tasks", "delete") is False From 69dd5069dc9cbfaf863c2fca571e58ba119667a2 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:29:28 +1100 Subject: [PATCH 3/6] fix: add test docstrings and reset fixture per CodeRabbit review - Add docstrings to test methods for coverage threshold - Add autouse fixture to reset permission state between tests Co-Authored-By: Claude Opus 4.6 --- tests/test_permissions.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index c1877f8..7f89e85 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -123,58 +123,79 @@ class TestGetScopesForPermission: ) 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 scope from manage.""" scopes = get_scopes_for_permission("tasks", "full") assert TASKS_SCOPE in scopes 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"} +@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 From acc3e665d00ef31c47e39f40dba54fc63d19a72e Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:40:50 +1100 Subject: [PATCH 4/6] fix: assert cumulative readonly scope in tasks full-level test Addresses CodeRabbit Review 3 nitpick: verify TASKS_READONLY_SCOPE is present at full level, confirming cumulative scope expansion. Co-Authored-By: Claude Opus 4.6 --- tests/test_permissions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 7f89e85..51d3c7e 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -129,9 +129,10 @@ class TestGetScopesForPermission: assert TASKS_READONLY_SCOPE in scopes def test_tasks_full_includes_write_scope(self): - """Full level should include write scope from manage.""" + """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 def test_tasks_manage_is_valid_level(self): """tasks:manage should be accepted by parse_permissions_arg.""" From 0e7c2a231862db09b8534f835cac3a9873bf88f1 Mon Sep 17 00:00:00 2001 From: mickey-mikey <149929346+mickey-mikey@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:47:29 +1100 Subject: [PATCH 5/6] refactor: move test_tasks_manage_is_valid_level to TestParsePermissionsArg Addresses CodeRabbit Review 4 nitpick: the test validates parse_permissions_arg() so it belongs with that test class. Co-Authored-By: Claude Opus 4.6 --- tests/test_permissions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 51d3c7e..f88ee7c 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -73,6 +73,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.""" @@ -134,10 +139,6 @@ class TestGetScopesForPermission: assert TASKS_SCOPE in scopes assert TASKS_READONLY_SCOPE in scopes - 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"} @pytest.fixture(autouse=True) From c7b0afa74cfcf8356f2a40f5d36344275983fdc1 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 5 Mar 2026 08:52:48 -0500 Subject: [PATCH 6/6] readme cleanup --- README.md | 2 +- tests/test_permissions.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index fbad8ea..925714a 100644 --- a/README.md +++ b/README.md @@ -568,7 +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) +- 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/tests/test_permissions.py b/tests/test_permissions.py index f88ee7c..66f5d62 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -17,7 +17,6 @@ from auth.permissions import ( is_action_denied, parse_permissions_arg, set_permissions, - SERVICE_DENIED_ACTIONS, SERVICE_PERMISSION_LEVELS, ) from auth.scopes import ( @@ -140,7 +139,6 @@ class TestGetScopesForPermission: assert TASKS_READONLY_SCOPE in scopes - @pytest.fixture(autouse=True) def _reset_permissions_state(): """Ensure each test starts and ends with no active permissions."""