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 <noreply@anthropic.com>
This commit is contained in:
mickey-mikey
2026-03-04 15:04:31 +11:00
parent 89e1974984
commit 377791080c
4 changed files with 99 additions and 4 deletions

View File

@@ -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]]: