Skip to content

enh: Partial Scope Grants & Granular Permissions - #510

Merged
taylorwilsdon merged 18 commits into
mainfrom
issues/503
Feb 28, 2026
Merged

enh: Partial Scope Grants & Granular Permissions#510
taylorwilsdon merged 18 commits into
mainfrom
issues/503

Conversation

@taylorwilsdon

@taylorwilsdon taylorwilsdon commented Feb 25, 2026

Copy link
Copy Markdown
Owner

Partial scope grants, granular permissions, test fixes, oh my

Lots of folks have been asking for this so why not give the people what they want?

Closes #503
Closes #491

Summary by CodeRabbit

  • New Features
    • Granular per-service permission system with configurable levels and a new --permissions CLI option (mutually exclusive with --read-only); tools are filtered by allowed permission scopes.
    • Lightweight cache-busting for OAuth discovery endpoints to ensure scope-aware metadata responses.
  • Bug Fixes / Reliability
    • OAuth callback now tolerates partial scope grants and adjusts token handling to avoid refresh failures.
  • Documentation
    • README updated with granular-permissions guidance and examples.
  • Tests
    • New tests for granular permissions and scope behavior; improved robustness unwrapping non-standard tool objects.
  • Chores
    • Test runner config updated to explicitly ignore a manual test file.

@taylorwilsdon taylorwilsdon self-assigned this Feb 25, 2026
@taylorwilsdon taylorwilsdon added the enhancement New feature or request label Feb 25, 2026
@coderabbitai

coderabbitai Bot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a granular, per-service permission system with a new CLI --permissions, integrates permission-aware scope sourcing and tool filtering, relaxes OAuth token-scope validation to handle partial grants by reconstructing credentials with actually granted scopes, and updates tests/config and docs to support these behaviors. (34 words)

Changes

Cohort / File(s) Summary
Permission System Core
auth/permissions.py
New module defining SERVICE_PERMISSION_LEVELS, parsing (parse_permissions_arg), state management (set_permissions/get_permissions/is_permissions_mode), and utilities to compute cumulative and allowed scopes per configured permission levels.
OAuth Callback & Scopes
auth/google_auth.py, auth/scopes.py
auth/google_auth.py ensures OAUTHLIB_RELAX_TOKEN_SCOPE=1, detects partial grants (granted vs requested scopes), logs a warning and reconstructs Credentials with granted scopes. auth/scopes.py gains an early-return path to use permission-derived scopes when permissions mode is active.
Tool Filtering & CLI Integration
core/tool_registry.py, main.py
core/tool_registry.py adds permissions-mode checks to disable tools whose required scopes aren't covered by allowed permission scopes and updates mode reporting. main.py adds --permissions CLI option, parses/applies permissions, enforces mutual exclusivity with --read-only/--tools, and adds resolve_permissions_mode_selection and narrow_permissions_to_services.
Config & Tests
pyproject.toml, tests/gchat/test_chat_tools.py, tests/test_scopes.py
PyTest config switched from collect_ignore_glob to addopts ignore. Test adjustments: unwrap uses getattr(tool, "fn", tool) for robustness; new tests exercise granular permissions and public API exposure (BASE_SCOPES, get_scopes_for_permission, set_permissions).
Docs & Server Middleware
README.md, core/server.py
README updated with --permissions guidance and examples. core/server.py adds OAuthMetadataCacheBustMiddleware and _compute_scope_fingerprint() to set no-store/ETag for /.well-known/ discovery endpoints.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI (main.py)
    participant Perm as auth.permissions
    participant ToolReg as core.tool_registry
    participant Scopes as auth.scopes
    participant OAuth as auth.google_auth

    CLI->>Perm: parse_permissions_arg(perms)
    Perm-->>CLI: parsed permissions dict
    CLI->>Perm: set_permissions(parsed)
    Perm-->>CLI: permissions active

    CLI->>ToolReg: initialize/load tools
    ToolReg->>Perm: is_permissions_mode()
    Perm-->>ToolReg: True
    ToolReg->>Perm: get_allowed_scopes_set()
    Perm-->>ToolReg: allowed scopes set
    ToolReg->>ToolReg: disable tools whose required scopes ⊄ allowed set
    ToolReg-->>CLI: filtered tool list

    Scopes->>Perm: is_permissions_mode()
    Perm-->>Scopes: True
    Scopes->>Perm: get_all_permission_scopes()
    Perm-->>Scopes: cumulative permission scopes
    Scopes-->>CLI: BASE_SCOPES + permission scopes

    OAuth->>OAuth: exchange code -> credentials
    OAuth->>OAuth: detect partial grant (granted_scopes vs requested)
    OAuth->>OAuth: rebuild Credentials with granted scopes
    OAuth-->>CLI: auth complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibble at scopes, clover by clover,
Levels stacked neat, each permission a rover,
Partial grants caught, stitched with gentle paws,
Tools pruned tidy, no overreaching claws,
Hop—new permissions, carrot applause 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is largely incomplete. It lacks most required template sections: Type of Change, Testing, Checklist, and Additional Notes are missing. Only a minimal summary is provided. Fill in all required template sections including Type of Change (New feature), Testing status, completion of the provided checklist items, and detailed Additional Notes about the implementation approach.
Docstring Coverage ⚠️ Warning Docstring coverage is 72.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive While most changes align with the stated objectives, some modifications appear tangentially related: OAuth metadata cache-busting in core/server.py, pyproject.toml pytest config changes, and test helper adjustments may exceed core requirements. Clarify the rationale for cache-busting middleware and pytest configuration changes, and confirm whether they are necessary for the core partial scope and permissions features or are separate improvements.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'enh: Partial Scope Grants & Granular Permissions' directly and clearly summarizes the two main features added: partial OAuth scope handling and granular permission levels.
Linked Issues check ✅ Passed The PR fully addresses both linked issues: #503 (partial scope grant handling in google_auth.py with OAUTHLIB_RELAX_TOKEN_SCOPE and granted scope detection) and #491 (granular permissions with --permissions CLI flag, PERMISSION_LEVELS, tool filtering, and scope resolution across multiple files).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issues/503

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for granular per-service permission levels and partial OAuth scope grants, aligning tool availability and requested scopes with more constrained user intent while fixing related test/config issues.

Changes:

  • Introduces --permissions service:level ... CLI flag and permission-mode tool loading/filtering.
  • Adds granular permission scope mapping (auth/permissions.py) and integrates it into scope generation + tool filtering.
  • Relaxes OAuth scope mismatch handling and updates tests/pytest configuration.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
uv.lock Bumps workspace-mcp version to 1.13.0.
pyproject.toml Updates pytest config to ignore the Apps Script manual test file via addopts.
main.py Adds --permissions, validates mutual exclusivity with --read-only, and wires permissions into tool loading + display.
core/tool_registry.py Adds permissions-mode filtering based on allowed scopes and adjusts mode logging/behavior.
auth/scopes.py Overrides scope generation when permissions mode is active (lazy import to avoid circular init).
auth/permissions.py New module defining per-service permission levels, scope expansion, and CLI parsing.
auth/google_auth.py Allows partial scope grants and stores granted (not requested) scopes when they differ.
tests/gchat/test_chat_tools.py Makes test helper _unwrap tolerant of tools that don’t expose .fn.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.py Outdated
Comment on lines +163 to +167
"Granular per-service permission levels. Format: service:level. "
"Example: --permissions gmail:organize drive:readonly. "
"Gmail levels: readonly, organize, drafts, send, full (cumulative). "
"Other services: readonly, full. "
"Mutually exclusive with --read-only."

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --permissions help text implies Gmail permission levels are truly granular (e.g., organize vs send), but organize maps to GMAIL_MODIFY_SCOPE (see auth/permissions.py), and gmail.modify is treated as covering gmail.send/gmail.compose in the project’s scope hierarchy. This can mislead users about what they’re actually granting on Google’s consent screen. Consider clarifying in the CLI help that some Google scopes are inherently broader (so levels mainly affect which tools are enabled), or adjust the level mappings/names accordingly.

Suggested change
"Granular per-service permission levels. Format: service:level. "
"Example: --permissions gmail:organize drive:readonly. "
"Gmail levels: readonly, organize, drafts, send, full (cumulative). "
"Other services: readonly, full. "
"Mutually exclusive with --read-only."
"Per-service permission levels used by this app to control which tools and features are enabled. "
"Format: service:level. Example: --permissions gmail:organize drive:readonly. "
"Gmail levels: readonly, organize, drafts, send, full (cumulative). Other services: readonly, full. "
"Note: some Google OAuth scopes (for example Gmail's modify scope) are inherently broader, so multiple "
"Gmail levels may map to the same underlying Google scope on the consent screen; the levels here mainly "
"affect which tools are available. Mutually exclusive with --read-only."

Copilot uses AI. Check for mistakes.
Comment thread core/tool_registry.py
Comment on lines +158 to +161
# No scope hierarchy expansion here — permission levels are already cumulative
# and explicitly define allowed scopes. Hierarchy expansion would defeat the
# purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the
# "organize" permission level intentionally excludes gmail.send).

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment suggests granular permissions can intentionally exclude gmail.send even though gmail.modify is present at lower levels; however, elsewhere in the codebase gmail.modify is explicitly treated as covering gmail.send via SCOPE_HIERARCHY (auth/scopes.py). Since the OAuth consent will still reflect the broader gmail.modify scope, the comment is misleading—please reword to reflect that hierarchy expansion is skipped only for tool filtering, not to change what the upstream OAuth scope implies.

Suggested change
# No scope hierarchy expansion here — permission levels are already cumulative
# and explicitly define allowed scopes. Hierarchy expansion would defeat the
# purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the
# "organize" permission level intentionally excludes gmail.send).
# No scope hierarchy expansion is applied here for tool filtering — permission
# levels already define the exact scopes that should enable a tool. We use the
# scopes from permissions mode as-is when deciding which tools to keep, even
# though upstream OAuth consent and SCOPE_HIERARCHY still treat broader scopes
# (e.g. gmail.modify) as covering narrower ones (e.g. gmail.send).

Copilot uses AI. Check for mistakes.
Comment thread auth/scopes.py
)
return list(set(scopes))
except ImportError:
pass

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_scopes_for_tools() now has a new behavior branch for granular permissions mode, but there are no corresponding unit tests. Since this function already has focused tests in tests/test_scopes.py, please add coverage for permissions mode (e.g., setting permissions then asserting returned scopes are BASE_SCOPES + permission scopes, and that non-permitted tool scopes aren’t included).

Suggested change
pass
logger.debug(
"auth.permissions not available; falling back to tool-based scope mapping",
exc_info=True,
)

Copilot uses AI. Check for mistakes.
Comment thread auth/google_auth.py
Comment on lines +524 to +532
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
)

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When handling partial grants, a new Credentials(...) object is constructed with scopes=list(granted) but other fields from the original credentials (notably id_token, and potentially token metadata) are dropped. This file later relies on credentials.id_token for session/email extraction and logging, so the partial-grant path can cause avoidable warnings/behavior differences. Preserve the extra credential fields when recreating the object (or update the scopes on the existing credentials if supported).

Suggested change
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
)
# Preserve all credential metadata when adjusting scopes for a
# partial OAuth grant. Prefer using with_scopes() if available so
# fields like id_token and quota_project_id are retained.
if hasattr(credentials, "with_scopes") and callable(
getattr(credentials, "with_scopes")
):
credentials = credentials.with_scopes(list(granted))
else:
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
id_token=getattr(credentials, "id_token", None),
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
quota_project_id=getattr(credentials, "quota_project_id", None),
)

Copilot uses AI. Check for mistakes.
Comment thread auth/permissions.py
Comment on lines +213 to +242
levels = SERVICE_PERMISSION_LEVELS.get(service)
if levels is None:
return []
return [name for name, _ in levels]


def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]:
"""
Parse --permissions arguments like ["gmail:organize", "drive:full"].

Returns dict mapping service -> level.
Raises ValueError on parse errors (unknown service, invalid level, bad format).
"""
result: Dict[str, str] = {}
for entry in permissions_list:
if ":" not in entry:
raise ValueError(
f"Invalid permission format: '{entry}'. "
f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')"
)
service, level = entry.split(":", 1)
if service in result:
raise ValueError(f"Duplicate service in permissions: '{service}'")
if service not in SERVICE_PERMISSION_LEVELS:
raise ValueError(
f"Unknown service: '{service}'. "
f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}"
)
valid = get_valid_levels(service)
if level not in valid:

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new permissions parsing/mapping logic is non-trivial and currently untested. Please add unit tests for parse_permissions_arg() (valid parsing, duplicate service, bad format, unknown service/level) and for cumulative scope expansion in get_scopes_for_permission() to prevent regressions as services/levels evolve.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
auth/permissions.py (1)

78-81: docs:full includes DRIVE_READONLY_SCOPE redundantly alongside DRIVE_FILE_SCOPE.

Since scopes are cumulative, docs:full already inherits DRIVE_READONLY_SCOPE from the readonly level. Including it again in the full level's additional scopes doesn't cause a bug (deduplication via set handles it), but it's unnecessary noise.

Proposed cleanup
     "docs": [
         ("readonly", [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]),
-        ("full", [DOCS_WRITE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE]),
+        ("full", [DOCS_WRITE_SCOPE, DRIVE_FILE_SCOPE]),
     ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@auth/permissions.py` around lines 78 - 81, The "docs" permission mapping in
auth/permissions.py redundantly lists DRIVE_READONLY_SCOPE in the "full" tuple
alongside DRIVE_FILE_SCOPE; remove DRIVE_READONLY_SCOPE from the second tuple so
"full" only includes the additional scopes (DOCS_WRITE_SCOPE and
DRIVE_FILE_SCOPE), keeping the "readonly" tuple unchanged and relying on
cumulative scope handling.
core/tool_registry.py (1)

136-155: Consider extracting the duplicated scope-check logic into a shared helper.

Steps 3 (read-only filtering, lines 138-155) and 4 (permissions filtering, lines 162-181) share the same pattern: unwrap tool_obj.fn, read _required_google_scopes, check all(scope in allowed for scope in required). A small helper like _get_tool_required_scopes(tool_obj) could reduce duplication.

Also applies to: 162-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/tool_registry.py` around lines 136 - 155, Extract the duplicated
scope-unwrapping and checking code into a small helper (e.g.,
_get_tool_required_scopes(tool_obj)) and reuse it from both the read-only branch
and the permissions branch; specifically, move the logic that sets func_to_check
= tool_obj / getattr(tool_obj, "fn"), reads required_scopes =
getattr(func_to_check, "_required_google_scopes", []), and then use that helper
in the read_only_mode block (where tools_to_remove is updated) and in the
permissions_mode block to perform the all(scope in allowed_scopes/allowed for
scope in required_scopes) check and logging. Ensure callers still reference
tool_components, tools_to_remove, allowed_scopes (and the equivalent allowed
variable in the permissions section) and keep the same logging message when
disabling a tool.
auth/google_auth.py (1)

485-489: Setting OAUTHLIB_RELAX_TOKEN_SCOPE globally affects the process environment.

This env var is set as a side effect in the callback handler and is never cleaned up. While it's guarded by an existence check, it permanently relaxes scope validation for the entire process, including any other OAuth flows or libraries sharing the same process. This is likely acceptable for this application, but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@auth/google_auth.py` around lines 485 - 489, The code sets
OAUTHLIB_RELAX_TOKEN_SCOPE globally which permanently alters process-wide
behavior; instead, set it only for the duration of the OAuth token handling and
restore the previous state afterwards: in the callback/token-exchange function
where the current snippet appears, save the previous
os.environ.get("OAUTHLIB_RELAX_TOKEN_SCOPE") value, set
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" only while performing the token
exchange (or call into oauthlib), then restore the saved value (delete the key
if it was None) immediately after—this confines the relaxation to the local
operation and avoids a permanent process-wide side effect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@auth/google_auth.py`:
- Around line 524-532: The reconstructed Credentials in the partial grant path
omits id_token; update the Credentials(...) call inside the partial-grant
handling (the block that rebuilds credentials using scopes=list(granted)) to
include id_token=credentials.id_token so the newly created Credentials preserves
the original id_token; this ensures save_credentials_to_session() can decode the
id_token and correctly write to the session cache (which currently fails
silently when id_token is missing).

In `@main.py`:
- Around line 306-313: The tier-resolution error handler uses safe_print which
can be suppressed; replace the safe_print call in the except block that handles
resolve_tools_from_tier(args.tool_tier, tools_to_import) with an unconditional
stderr print so the message is always visible before exit (e.g., use print(...,
file=sys.stderr) or write to sys.stderr) while keeping the same message text and
leaving sys.exit(1), and ensure this change is applied in the block that calls
resolve_tools_from_tier and set_enabled_tool_names.

---

Nitpick comments:
In `@auth/google_auth.py`:
- Around line 485-489: The code sets OAUTHLIB_RELAX_TOKEN_SCOPE globally which
permanently alters process-wide behavior; instead, set it only for the duration
of the OAuth token handling and restore the previous state afterwards: in the
callback/token-exchange function where the current snippet appears, save the
previous os.environ.get("OAUTHLIB_RELAX_TOKEN_SCOPE") value, set
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" only while performing the token
exchange (or call into oauthlib), then restore the saved value (delete the key
if it was None) immediately after—this confines the relaxation to the local
operation and avoids a permanent process-wide side effect.

In `@auth/permissions.py`:
- Around line 78-81: The "docs" permission mapping in auth/permissions.py
redundantly lists DRIVE_READONLY_SCOPE in the "full" tuple alongside
DRIVE_FILE_SCOPE; remove DRIVE_READONLY_SCOPE from the second tuple so "full"
only includes the additional scopes (DOCS_WRITE_SCOPE and DRIVE_FILE_SCOPE),
keeping the "readonly" tuple unchanged and relying on cumulative scope handling.

In `@core/tool_registry.py`:
- Around line 136-155: Extract the duplicated scope-unwrapping and checking code
into a small helper (e.g., _get_tool_required_scopes(tool_obj)) and reuse it
from both the read-only branch and the permissions branch; specifically, move
the logic that sets func_to_check = tool_obj / getattr(tool_obj, "fn"), reads
required_scopes = getattr(func_to_check, "_required_google_scopes", []), and
then use that helper in the read_only_mode block (where tools_to_remove is
updated) and in the permissions_mode block to perform the all(scope in
allowed_scopes/allowed for scope in required_scopes) check and logging. Ensure
callers still reference tool_components, tools_to_remove, allowed_scopes (and
the equivalent allowed variable in the permissions section) and keep the same
logging message when disabling a tool.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9631b9e and 86a8e1b.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • auth/google_auth.py
  • auth/permissions.py
  • auth/scopes.py
  • core/tool_registry.py
  • main.py
  • pyproject.toml
  • tests/gchat/test_chat_tools.py

Comment thread auth/google_auth.py
Comment thread main.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread auth/permissions.py
Comment on lines +219 to +247
def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]:
"""
Parse --permissions arguments like ["gmail:organize", "drive:full"].

Returns dict mapping service -> level.
Raises ValueError on parse errors (unknown service, invalid level, bad format).
"""
result: Dict[str, str] = {}
for entry in permissions_list:
if ":" not in entry:
raise ValueError(
f"Invalid permission format: '{entry}'. "
f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')"
)
service, level = entry.split(":", 1)
if service in result:
raise ValueError(f"Duplicate service in permissions: '{service}'")
if service not in SERVICE_PERMISSION_LEVELS:
raise ValueError(
f"Unknown service: '{service}'. "
f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}"
)
valid = get_valid_levels(service)
if level not in valid:
raise ValueError(
f"Unknown level '{level}' for service '{service}'. "
f"Valid levels: {valid}"
)
result[service] = level

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth/permissions.py introduces core parsing/validation logic for --permissions (including error cases like bad format, unknown service, invalid level, duplicates) but there are no unit tests exercising these behaviors. Adding tests for parse_permissions_arg() and get_scopes_for_permission() would lock in the expected CLI contract and scope expansion rules.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
main.py (1)

306-311: Tier filtering should also narrow imported services in permissions mode.

At Line 309, tier resolution returns both tool names and service names, but only tool names are applied. This still imports all permission-selected services, even when tier filtering narrows active tools.

Proposed refinement
-                tier_tools, _ = resolve_tools_from_tier(args.tool_tier, tools_to_import)
+                tier_tools, tier_services = resolve_tools_from_tier(
+                    args.tool_tier, tools_to_import
+                )
                 set_enabled_tool_names(set(tier_tools))
+                tools_to_import = tier_services
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main.py` around lines 306 - 311, The tier resolution step using
resolve_tools_from_tier(args.tool_tier, tools_to_import) returns both tool names
and service names, but only the tool names are applied via
set_enabled_tool_names(tier_tools), so permission-selected services still import
all services; modify the block after resolve_tools_from_tier to capture both
outputs (e.g., tier_tools, tier_services = resolve_tools_from_tier(...)) and
then apply filtering to imported/permission-selected services by intersecting
tools_to_import (or the structure holding selected services) with tier_services
so only services included by the tier remain imported, while still calling
set_enabled_tool_names with the tier_tools.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@main.py`:
- Around line 306-311: The tier resolution step using
resolve_tools_from_tier(args.tool_tier, tools_to_import) returns both tool names
and service names, but only the tool names are applied via
set_enabled_tool_names(tier_tools), so permission-selected services still import
all services; modify the block after resolve_tools_from_tier to capture both
outputs (e.g., tier_tools, tier_services = resolve_tools_from_tier(...)) and
then apply filtering to imported/permission-selected services by intersecting
tools_to_import (or the structure holding selected services) with tier_services
so only services included by the tier remain imported, while still calling
set_enabled_tool_names with the tier_tools.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 86a8e1b and e394ad9.

📒 Files selected for processing (2)
  • auth/google_auth.py
  • main.py

@taylorwilsdon taylorwilsdon changed the title enh: Issues/503 enh: Partial Scope Grants & Granular Permissions Feb 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@main.py`:
- Around line 194-202: The startup currently ignores args.tools when
args.permissions is provided, causing ambiguous exposure; add explicit
validation in the same block that checks args.permissions (the one that already
checks args.read_only) to either reject the combination or implement
deterministic semantics: if rejecting, print a clear stderr error and
sys.exit(1) when args.permissions and args.tools are both set; if allowing,
compute the intersection by filtering args.tools against allowed entries derived
from args.permissions (e.g., only keep tools explicitly permitted by the parsed
permissions list) and document that behavior in the startup log—update the
branch that references args.permissions where tools are later used (the logic
around lines handling args.tools) to rely on this new validation/normalization.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e90782 and f2986dc.

📒 Files selected for processing (2)
  • README.md
  • main.py

Comment thread main.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.py
try:
tools_to_import, tier_tool_filter = resolve_permissions_mode_selection(
tools_to_import, args.tool_tier
)

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In --permissions mode combined with --tool-tier, permissions are set before tier resolution and never narrowed to the tier-selected services. As a result, OAuth scope generation will still request scopes for services that were filtered out (and the “Permission Levels” output will still list them), which undermines least-privilege and can confuse users. Consider applying tier filtering first (or reapplying set_permissions) so the stored permissions/scopes only include the final set of imported services when --tool-tier is used.

Suggested change
)
)
# Narrow permissions/scopes to only the tier-selected services
if perms is not None:
perms = {service: perms[service] for service in tools_to_import if service in perms}
set_permissions(perms)

Copilot uses AI. Check for mistakes.
Comment thread auth/scopes.py
Comment on lines +294 to +309
# Granular permissions mode overrides both full and read-only scope maps.
# Lazy import with guard to avoid circular dependency during module init
# (SCOPES = get_scopes_for_tools() runs at import time before auth.permissions
# is fully loaded, but permissions mode is never active at that point).
try:
from auth.permissions import is_permissions_mode, get_all_permission_scopes

if is_permissions_mode():
scopes = BASE_SCOPES.copy()
scopes.extend(get_all_permission_scopes())
logger.debug(
"Generated scopes from granular permissions: %d unique scopes",
len(set(scopes)),
)
return list(set(scopes))
except ImportError:

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New granular-permissions scope resolution path in get_scopes_for_tools() isn’t covered by the existing scopes tests. Please add unit tests that: (1) set granular permissions, (2) verify returned scopes are BASE_SCOPES + the expected cumulative permission scopes, and (3) verify it correctly overrides read-only/full maps.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_scopes.py (1)

206-212: Consider adding a clear_permissions() helper in auth.permissions.

Directly assigning permissions_module._PERMISSIONS = None couples tests to internal state. A public clear_permissions() function would be cleaner and more maintainable.

Proposed helper in auth/permissions.py
def clear_permissions() -> None:
    """Reset granular permissions state (primarily for testing)."""
    global _PERMISSIONS
    _PERMISSIONS = None

Then in tests:

-        permissions_module._PERMISSIONS = None
+        permissions_module.clear_permissions()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_scopes.py` around lines 206 - 212, Add a public
clear_permissions() helper to auth.permissions that resets the module-level
_PERMISSIONS to None (e.g., def clear_permissions() -> None: global
_PERMISSIONS; _PERMISSIONS = None) and update tests
(setup_method/teardown_method in tests/test_scopes.py) to call
permissions_module.clear_permissions() instead of directly assigning
permissions_module._PERMISSIONS = None so tests avoid touching internal state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/test_scopes.py`:
- Around line 206-212: Add a public clear_permissions() helper to
auth.permissions that resets the module-level _PERMISSIONS to None (e.g., def
clear_permissions() -> None: global _PERMISSIONS; _PERMISSIONS = None) and
update tests (setup_method/teardown_method in tests/test_scopes.py) to call
permissions_module.clear_permissions() instead of directly assigning
permissions_module._PERMISSIONS = None so tests avoid touching internal state.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f2986dc and 58256e4.

📒 Files selected for processing (3)
  • core/server.py
  • main.py
  • tests/test_scopes.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.py Outdated
"Example: --permissions gmail:organize drive:readonly. "
"Gmail levels: readonly, organize, drafts, send, full (cumulative). "
"Other services: readonly, full. "
"Mutually exclusive with --read-only."

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --permissions CLI help text says it’s only mutually exclusive with --read-only, but the argument validation below also rejects combining --permissions with --tools. Update the help string (and/or argparse mutually-exclusive group) so the CLI docs match actual behavior and users get consistent guidance.

Suggested change
"Mutually exclusive with --read-only."
"Mutually exclusive with --read-only and --tools."

Copilot uses AI. Check for mistakes.
Comment thread README.md Outdated
- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative)
- Other services currently support: `readonly`, `full`
- `--permissions` and `--read-only` are mutually exclusive
- With `--tool-tier`, only tier-matched tools are enabled and only services with matching tier tools are imported

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README granular-permissions bullets don’t mention that --permissions cannot be combined with --tools, but main.py enforces that restriction. Please document this here (and ideally explain that service selection comes from --permissions, optionally with --tool-tier).

Suggested change
- With `--tool-tier`, only tier-matched tools are enabled and only services with matching tier tools are imported
- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`)
- With `--tool-tier`, only tier-matched tools are enabled and only services that have tools in the selected tier are imported

Copilot uses AI. Check for mistakes.
Comment thread auth/permissions.py
Comment thread auth/permissions.py
Comment on lines +219 to +248
def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]:
"""
Parse --permissions arguments like ["gmail:organize", "drive:full"].

Returns dict mapping service -> level.
Raises ValueError on parse errors (unknown service, invalid level, bad format).
"""
result: Dict[str, str] = {}
for entry in permissions_list:
if ":" not in entry:
raise ValueError(
f"Invalid permission format: '{entry}'. "
f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')"
)
service, level = entry.split(":", 1)
if service in result:
raise ValueError(f"Duplicate service in permissions: '{service}'")
if service not in SERVICE_PERMISSION_LEVELS:
raise ValueError(
f"Unknown service: '{service}'. "
f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}"
)
valid = get_valid_levels(service)
if level not in valid:
raise ValueError(
f"Unknown level '{level}' for service '{service}'. "
f"Valid levels: {valid}"
)
result[service] = level
return result

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_permissions_arg() has multiple validation branches (bad format, duplicate service, unknown service, invalid level) but there are no tests covering these error paths. Add unit tests for invalid inputs so the CLI-facing validation behavior is locked in.

Copilot uses AI. Check for mistakes.
@taylorwilsdon
taylorwilsdon merged commit d7e0277 into main Feb 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

2 participants