Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
37 changes: 33 additions & 4 deletions auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
--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,
Expand Down Expand Up @@ -97,7 +98,8 @@
],
"tasks": [
("readonly", [TASKS_READONLY_SCOPE]),
("full", [TASKS_SCOPE]),
("manage", [TASKS_SCOPE]),
("full", []),
],
"contacts": [
("readonly", [CONTACTS_READONLY_SCOPE]),
Expand Down Expand Up @@ -131,16 +133,43 @@
],
}

# 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"}),
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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]]:
Expand Down
11 changes: 11 additions & 0 deletions gtasks/tasks_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Expand Down
83 changes: 83 additions & 0 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

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 (
Expand All @@ -24,6 +27,8 @@
GMAIL_COMPOSE_SCOPE,
DRIVE_READONLY_SCOPE,
DRIVE_SCOPE,
TASKS_READONLY_SCOPE,
TASKS_SCOPE,
DRIVE_FILE_SCOPE,
)

Expand Down Expand Up @@ -116,3 +121,81 @@ def test_no_duplicate_scopes(self):
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 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.