Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions client/src/extrasuite/client/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,13 @@ def build_parser() -> Any:
auth_parent.add_argument(
"--reason",
"-r",
"-m",
metavar="TEXT",
default=None,
help=(
"Why this operation is being performed. "
"Pass the user's actual intent for audit trails. "
"Can also be set via the EXTRASUITE_REASON environment variable."
"Why this operation is being performed (required). "
"Pass the user's actual intent so the server audit log records "
"the real reason credentials were requested."
),
)

Expand Down
40 changes: 22 additions & 18 deletions client/src/extrasuite/client/cli/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,22 @@ def _auth_kwargs(args: Any) -> dict[str, Any]:
return kwargs


def _get_reason(args: Any, *, default: str) -> str:
"""Resolve the reason string for a token request.
def _get_reason(args: Any) -> str:
"""Get the reason string from CLI args.

Precedence: --reason CLI flag > EXTRASUITE_REASON env var > hardcoded default.
Raises SystemExit with a clear message if --reason was not provided.
This is required for all credentialed commands so the server audit log
records the actual user intent.
"""
import os

return (
getattr(args, "reason", None)
or os.environ.get("EXTRASUITE_REASON", "")
or default
)
reason = getattr(args, "reason", None)
if not reason:
print(
"This command requires --reason.\n"
"Provide the user's actual intent so the server audit log records why credentials were requested.",
file=sys.stderr,
)
sys.exit(1)
return reason


def _get_credential(args: Any, *, command: dict[str, Any], reason: str) -> Credential:
Expand Down Expand Up @@ -247,10 +251,13 @@ def _cmd_share(file_type: str, args: Any) -> None:
from extrasuite.client.google_api import share_file
from extrasuite.client.settings import load_trusted_contacts

# 1. Load trusted contacts (no user_domain injection — settings.toml is explicit)
# 1. Require reason before any other work
reason = _get_reason(args)

# 2. Load trusted contacts (no user_domain injection — settings.toml is explicit)
trusted = load_trusted_contacts()

# 2. Validate all emails before any API call
# 3. Validate all emails before any API call
untrusted = [e for e in args.emails if not trusted.is_trusted(e)]
if untrusted:
for e in untrusted:
Expand All @@ -261,11 +268,10 @@ def _cmd_share(file_type: str, args: Any) -> None:
)
sys.exit(1)

# 3. Parse file ID using type-specific URL parser
# 4. Parse file ID using type-specific URL parser
file_id = _URL_PARSERS[file_type](args.url)

# 4. Get DWD credential for drive.file.share
reason = _get_reason(args, default=f"Share {file_type} with users")
# 5. Get DWD credential for drive.file.share
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -312,9 +318,7 @@ def _cmd_create(file_type: str, args: Any) -> None:
manager = CredentialsManager(**_auth_kwargs(args))

# Get credential for drive.file.create — SA email is always in metadata
reason = _get_reason(
args, default=f"Create {file_type} and share with service account"
)
reason = _get_reason(args)
cred = manager.get_credential(
command={
"type": "drive.file.create",
Expand Down
18 changes: 8 additions & 10 deletions client/src/extrasuite/client/cli/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def cmd_calendar_view(args: Any) -> None:
)

time_min, time_max = parse_time_value(args.when)
reason = _get_reason(args, default="View calendar events")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand All @@ -43,7 +43,7 @@ def cmd_calendar_list(args: Any) -> None:
"""List all calendars the user has access to."""
from extrasuite.client.google_api import format_calendars_markdown, list_calendars

reason = _get_reason(args, default="List calendars")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "calendar.list"},
Expand Down Expand Up @@ -76,7 +76,7 @@ def cmd_calendar_search(args: Any) -> None:

time_max = time_min + timedelta(days=30)

reason = _get_reason(args, default="Search calendar events")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -115,7 +115,7 @@ def cmd_calendar_freebusy(args: Any) -> None:
sys.exit(1)

time_min, time_max = parse_time_value(args.when)
reason = _get_reason(args, default="Check free/busy")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand All @@ -139,6 +139,7 @@ def cmd_calendar_create(args: Any) -> None:
format_created_event_markdown,
)

reason = _get_reason(args)
json_path = args.json
if json_path == "-":
event_json = _json.load(sys.stdin)
Expand All @@ -159,8 +160,6 @@ def cmd_calendar_create(args: Any) -> None:
end_time = (event_json.get("end") or {}).get("dateTime", "") or (
event_json.get("end") or {}
).get("date", "")

reason = _get_reason(args, default="Create calendar event")
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -191,6 +190,7 @@ def cmd_calendar_update(args: Any) -> None:
update_calendar_event,
)

reason = _get_reason(args)
json_path = args.json
if json_path == "-":
patch_json = _json.load(sys.stdin)
Expand All @@ -204,8 +204,6 @@ def cmd_calendar_update(args: Any) -> None:
attendees = [
a.get("email", "") for a in patch_json.get("attendees", []) if a.get("email")
]

reason = _get_reason(args, default="Update calendar event")
cred = _get_credential(
args,
command={
Expand All @@ -231,7 +229,7 @@ def cmd_calendar_delete(args: Any) -> None:
"""Delete (cancel) a calendar event."""
from extrasuite.client.google_api import delete_calendar_event

reason = _get_reason(args, default="Delete calendar event")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -266,7 +264,7 @@ def cmd_calendar_rsvp(args: Any) -> None:
}
api_response = response_map.get(args.response, args.response)

reason = _get_reason(args, default="RSVP to calendar event")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand Down
8 changes: 4 additions & 4 deletions client/src/extrasuite/client/cli/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def cmd_contacts_sync(args: Any) -> None:
"""Sync Google Contacts to local DB."""
from extrasuite.client.contacts import sync

reason = _get_reason(args, default="Sync Google Contacts")
reason = _get_reason(args)
token = _get_credential(
args,
command={"type": "contacts.read", "query": ""},
Expand All @@ -22,7 +22,7 @@ def cmd_contacts_sync(args: Any) -> None:
other_token = _get_credential(
args,
command={"type": "contacts.other", "query": ""},
reason=_get_reason(args, default="Sync Gmail-suggested contacts"),
reason=_get_reason(args),
)
people_count, other_count = sync(
token.token, other_token=other_token.token, verbose=True
Expand Down Expand Up @@ -53,7 +53,7 @@ def cmd_contacts_search(args: Any) -> None:
other_token_str = None
if needs_sync:
query_str = " ".join(queries)
reason = _get_reason(args, default="Sync Google Contacts")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "contacts.read", "query": query_str},
Expand All @@ -62,7 +62,7 @@ def cmd_contacts_search(args: Any) -> None:
other_cred = _get_credential(
args,
command={"type": "contacts.other", "query": query_str},
reason=_get_reason(args, default="Sync Gmail-suggested contacts"),
reason=_get_reason(args),
)
token_str = cred.token
other_token_str = other_cred.token
Expand Down
4 changes: 2 additions & 2 deletions client/src/extrasuite/client/cli/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def cmd_doc_pull(args: Any) -> None:

document_id = _parse_document_id(args.url)
output_dir = Path(args.output_dir) if args.output_dir else Path()
reason = _get_reason(args, default="Pulling Google Doc")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "doc.pull", "file_url": args.url, "file_name": ""},
Expand Down Expand Up @@ -78,7 +78,7 @@ def cmd_doc_push(args: Any) -> None:
"""Push changes to a Google Doc."""
from extradoc import DocsClient, GoogleDocsTransport

reason = _get_reason(args, default="Pushing changes to Google Doc")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "doc.push", "file_url": "", "file_name": ""},
Expand Down
4 changes: 2 additions & 2 deletions client/src/extrasuite/client/cli/drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def cmd_drive_ls(args: Any) -> None:
query_parts.append(f"'{folder_id}' in parents")

query = " and ".join(query_parts)
reason = _get_reason(args, default="Listing Drive files")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "drive.ls", "folder_url": folder_url, "query": query},
Expand All @@ -44,7 +44,7 @@ def cmd_drive_search(args: Any) -> None:
"""Search files visible to the service account in Google Drive."""
from extrasuite.client.google_api import format_drive_files, list_drive_files

reason = _get_reason(args, default="Searching Drive files")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "drive.search", "query": args.query},
Expand Down
4 changes: 2 additions & 2 deletions client/src/extrasuite/client/cli/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def cmd_form_pull(args: Any) -> None:

form_id = _parse_form_id(args.url)
output_dir = Path(args.output_dir) if args.output_dir else Path()
reason = _get_reason(args, default="Pulling Google Form")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "form.pull", "file_url": args.url, "file_name": ""},
Expand Down Expand Up @@ -64,7 +64,7 @@ def cmd_form_push(args: Any) -> None:
"""Push changes to a Google Form."""
from extraform import FormsClient, GoogleFormsTransport

reason = _get_reason(args, default="Pushing changes to Google Form")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "form.push", "file_url": "", "file_name": ""},
Expand Down
17 changes: 7 additions & 10 deletions client/src/extrasuite/client/cli/gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,11 @@ def cmd_gmail_compose(args: Any) -> None:
"""Save an email draft from a markdown file with front matter."""
from extrasuite.client.google_api import create_gmail_draft

reason = _get_reason(args)
attach = getattr(args, "attach", None)
to, subject, body, cc, bcc, attachments = _parse_email_file_args(
Path(args.file), cli_attachments=attach
)

reason = _get_reason(args, default="Save email draft")
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -102,12 +101,11 @@ def cmd_gmail_edit_draft(args: Any) -> None:
"""Update an existing Gmail draft from a markdown file with front matter."""
from extrasuite.client.google_api import update_gmail_draft

reason = _get_reason(args)
attach = getattr(args, "attach", None)
to, subject, body, cc, bcc, attachments = _parse_email_file_args(
Path(args.file), cli_attachments=attach
)

reason = _get_reason(args, default="Edit email draft")
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -141,6 +139,9 @@ def cmd_gmail_reply(args: Any) -> None:
parse_email_file,
)

# gmail.reply maps to [gmail.readonly + gmail.compose] on the server —
# a single request returns a token valid for both scopes.
reason = _get_reason(args)
file_path = Path(args.file)
if not file_path.exists():
print(f"Error: File not found: {file_path}", file=sys.stderr)
Expand All @@ -158,10 +159,6 @@ def cmd_gmail_reply(args: Any) -> None:
print(f"Error: Attachment not found: {p}", file=sys.stderr)
sys.exit(1)
attachments.append(p)

# gmail.reply maps to [gmail.readonly + gmail.compose] on the server —
# a single request returns a token valid for both scopes.
reason = _get_reason(args, default="Save reply draft")
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -229,7 +226,7 @@ def cmd_gmail_list(args: Any) -> None:
)

query = getattr(args, "query", "") or ""
reason = _get_reason(args, default="List Gmail threads")
reason = _get_reason(args)
cred = _get_credential(
args,
command={
Expand Down Expand Up @@ -286,7 +283,7 @@ def cmd_gmail_read(args: Any) -> None:
get_thread,
)

reason = _get_reason(args, default="Read Gmail thread")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "gmail.read", "thread_id": args.thread_id},
Expand Down
6 changes: 3 additions & 3 deletions client/src/extrasuite/client/cli/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def cmd_script_pull(args: Any) -> None:

script_id = parse_script_id(args.url)
output_dir = Path(args.output_dir) if args.output_dir else Path()
reason = _get_reason(args, default="Pull Apps Script project")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "script.pull", "file_url": args.url, "file_name": ""},
Expand Down Expand Up @@ -61,7 +61,7 @@ def cmd_script_push(args: Any) -> None:
"""Push changes to a Google Apps Script project."""
from extrascript import GoogleAppsScriptTransport, ScriptClient

reason = _get_reason(args, default="Push Apps Script project")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "script.push", "file_url": "", "file_name": ""},
Expand Down Expand Up @@ -99,7 +99,7 @@ def cmd_script_create(args: Any) -> None:
from extrascript import GoogleAppsScriptTransport, ScriptClient
from extrascript.client import parse_file_id

reason = _get_reason(args, default="Create Apps Script project")
reason = _get_reason(args)
bind_to = args.bind_to or ""
cred = _get_credential(
args,
Expand Down
7 changes: 3 additions & 4 deletions client/src/extrasuite/client/cli/sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def cmd_sheet_pull(args: Any) -> None:

spreadsheet_id = _parse_spreadsheet_id(args.url)
output_dir = Path(args.output_dir) if args.output_dir else Path()
reason = _get_reason(args, default="Pulling Google Sheet")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "sheet.pull", "file_url": args.url, "file_name": ""},
Expand Down Expand Up @@ -96,7 +96,7 @@ def cmd_sheet_push(args: Any) -> None:

from extrasheet import GoogleSheetsTransport, SheetsClient

reason = _get_reason(args, default="Pushing changes to Google Sheet")
reason = _get_reason(args)
cred = _get_credential(
args,
command={"type": "sheet.push", "file_url": "", "file_name": ""},
Expand Down Expand Up @@ -125,6 +125,7 @@ def cmd_sheet_batchupdate(args: Any) -> None:

from extrasheet import GoogleSheetsTransport

reason = _get_reason(args)
spreadsheet_id = _parse_spreadsheet_id(args.url)
requests_path = Path(args.requests_file)
if not requests_path.exists():
Expand All @@ -141,8 +142,6 @@ def cmd_sheet_batchupdate(args: Any) -> None:
"Error: Expected a list of requests or {requests: [...]}", file=sys.stderr
)
sys.exit(1)

reason = _get_reason(args, default="Executing batchUpdate on Google Sheet")
cred = _get_credential(
args,
command={
Expand Down
Loading