| health_checkA | Report service status, version, and per-domain auth/API reachability. Always returns the same keys: status (healthy / degraded / error), service,
version, config, and domains (per-domain auth result). Lightweight: one
1-item login query per domain. |
| login_auditA | Audit the login log: Google-auto-disabled accounts, suspicious logins, failure top-N. Answers "did Google itself decide something was wrong with an account here,
and with which account?" — the question a "my mail suddenly stopped working"
ticket usually turns out to be. account_disabled_* is Google reporting
that it locked an account (leaked password, hijacking, spamming);
suspicious_login and gov_attack_warning are warnings without a lock. In both sections user is the account the event is ABOUT, not an actor
who did something: Google raised these itself, so the actor is Google and
the account is read from the event's affected_email_address. Treat a
hit as evidence about that account, and reach for get_user next for its
current state. An account_disabled_spamming entry means Google observed outbound spam,
which is a compromise finding, not a delivery problem — the account was
almost certainly being used by someone else. The IdP upstream is a separate
system and is NOT disabled by this: an account locked here can still
authenticate there until it is disabled there too. Combine with a Directory suspended-users snapshot (Phase 2) for current
state. Each section carries capped (window not fully scanned) — treat
counts as lower bounds then. |
| suspended_accountsA | Snapshot of currently suspended Google Workspace accounts, per domain. A suspended-but-still-provisioned account is a common attack surface: an
account disabled in Google may remain enabled in a downstream IdP (e.g.
KeyCloak), where a password-spray attacker can still authenticate through
it. Cross-reference this list against the IdP to find and disable such gaps. Unlike login_audit (which reports the event of Google disabling an
account within a time window), this is current state — every account
suspended right now, regardless of when. Read-only (Directory API
users().list with query=isSuspended=true). Requires the
admin.directory.user.readonly DWD scope; a domain missing that grant
degrades to {"error": ...} for that domain only. capped is set when
max_pages was hit before the listing was exhausted. Coverage is per configured domain (Directory domain= filter), unlike the
customer-wide Reports tools — every domain you want covered (e.g. a separate
student domain) must have its own [domain.*] config section, or its
suspended accounts are not listed. Args:
domain: Restrict to one configured domain (default: all).
max_pages: Page cap (500 accounts/page); capped=true means more exist. |
| get_userA | Look up ONE named account's current state — the "why can't this person sign in" tool. Answers a helpdesk ticket that already names the exact address: is the
account suspended (and for what reason, since when), archived, enrolled in
or enforced into 2-step verification, when did it last log in, which org
unit is it in, is a password change pending. One Directory API request, no
pagination. Use this — not suspended_accounts — whenever the address is known.
That tool lists only accounts that ARE suspended, so it can never confirm
that a given address is not suspended, and once that list exceeds its
page cap absence stops being evidence either way — after spending far more
API calls than this. suspended_accounts is for the domain-wide sweep it
is actually named for. This directory is downstream of the identity provider, not the master. Read
the answer as "what Google Workspace currently believes about this account"
and compare it against the IdP's own record, which is authoritative for who
the account is. A disagreement is usually drift on this side rather than a
mistyped address — an account the IdP still authenticates can be suspended
or archived here, and an address the IdP does not assert at all will simply
come back found: false. An address that names no account returns found: false with no state
fields. That is a normal, expected answer — a typo'd or long-deleted
address — and is itself the diagnostic result, NOT a failure. A missing
DWD scope, a rejected credential or a transient API failure is reported as
{"error": ...} instead. The two are deliberately distinct: never read
found: false as "the lookup did not work", and never read an error
as evidence about whether the account exists. Read-only (Directory API users().get; no mutating method exists in
this package). Requires the admin.directory.user.readonly DWD scope —
the same one suspended_accounts uses, so a tenant already running that
tool needs no additional grant. Args:
username: Exact user email, passed through as the Directory API
userKey (primary or alias address both work on Google's side;
the returned email is the account's canonical primary one).
domain: Configured [domain.*] section to route the lookup through.
Default: resolved from the username's suffix. Set it explicitly
when the address uses an alias/secondary domain that has no
config section of its own (common when copying addresses from
mail headers or IdP logs). |
| user_oauth_tokensA | List third-party OAuth apps one user has granted account access to. Account-compromise triage tool for the case login_audit and
suspended_accounts are both blind to: a malicious app used a
previously-granted OAuth token to read/delete mail or Drive files without
ever generating a fresh login event. Check each entry's scopes for
Gmail/Drive access on an unrecognized client_id/display_text —
Google's own apps (e.g. iOS/Android account sync) show up too and are
normal noise. Read-only (Directory API tokens().list; never tokens().delete()).
Requires the admin.directory.user.security DWD scope — distinct from
admin.directory.user.readonly used by suspended_accounts; a domain
missing that grant returns {"error": ...}. No pagination: the API
returns a user's full grant list in one response. Args:
username: Exact user email, passed through as the Directory API
userKey (primary or alias address both work on Google's side).
domain: Configured [domain.*] section to route the lookup through.
Default: resolved from the username's suffix. Set it explicitly
when the address uses an alias/secondary domain that has no
config section of its own (common when copying addresses from
mail headers or IdP logs). |
| gmail_message_traceA | Check whether a message (by RFC 822 Message-ID) reached specific users' mailboxes. Answers "who got this email and who didn't" for a KNOWN Message-ID and a
KNOWN candidate recipient list — there is no Workspace API to search
across every user for one message, so the caller supplies who to check
(a mailing-list roster, or simply the people who reported a problem).
For each recipient this impersonates that exact user via domain-wide
delegation and searches their own mailbox (including Spam and Trash) for
the Message-ID. Requires the gmail.readonly DWD scope — granted PER SERVICE ACCOUNT
CLIENT ID in the Admin console (Security > API controls > Domain-wide
delegation), separately from the admin.directory.* / admin.reports.*
scopes the rest of this server uses, and NOT on by default. A domain
missing that grant reports a per-recipient error rather than a
silent "not found" — the two must never be confused, since "not found"
here can also legitimately mean the message was delivered and later
deleted by the user, or never delivered at all; this tool cannot tell
those apart, only "a match currently exists in this mailbox" from "it
doesn't". Read-only: only messages().list and messages().get (metadata
only, never the message body) are issued against each impersonated
mailbox — see DomainClient.find_message_by_id. A per-recipient result sets ambiguous: true (with match_count)
when more than one message in that mailbox shares the Message-ID (e.g. a
mailing-list copy plus a direct CC) — the other fields describe only the
first match in that case, not a combined answer. match_count_capped
is set alongside it when the mailbox has enough matches that
match_count itself is a lower bound, not exact. Args:
message_id: The RFC 822 Message-ID to search for, with or without
angle brackets. Must be shaped like an address (local@domain,
no whitespace) — this is validated before use, since it is
interpolated into a Gmail search query.
recipients: Comma- and/or whitespace-separated exact recipient email
addresses to check (max 50 per call — split a larger list across
multiple calls rather than expecting a partial result).
domain: Configured [domain.*] section to route EVERY recipient
through. Default: resolved per-recipient from their own address
suffix, so one call can cover a mixed staff/student list. Set
this only when recipients use an alias/secondary domain with no
config section of its own. |
| group_delivery_policyA | Check a Google Group's own posting/delivery policy — why an external sender's mail never arrived. A Group's access-control layer sits IN FRONT of Gmail delivery: when
who_can_post is restricted (e.g. domain-members-only), an external
sender's message is rejected there and never generates a per-recipient
Gmail delivery event at all — gmail_message_trace (a real mailbox) and
any Reports-API-based delivery trace both see nothing for that address,
indistinguishable from a genuine delivery failure without this. Use this
FIRST when a group address "isn't receiving" mail from an external
sender, before chasing it as a transport/spam problem. Read-only: only groups().get() is issued (Groups Settings API).
Requires the apps.groups.settings DWD scope — granted PER SERVICE
ACCOUNT CLIENT ID in the Admin console (Security > API controls >
Domain-wide delegation), separately from every other scope this server
uses, and NOT on by default. Returns who_can_post (e.g. ALL_IN_DOMAIN_CAN_POST blocks external
senders entirely; ANYONE_CAN_POST allows them), allow_external_members,
is_archived, message_moderation_level, spam_moderation_level,
allow_web_posting. Sets found: false (no policy fields) when
group_email does not name any group in this domain — that is a
normal, expected answer for a bad/typo'd address, not an error. Args:
group_email: The group's address (e.g. "team.gen@example.edu").
domain: Configured [domain.*] section to route the lookup through.
Default: resolved from the address's suffix. |
| list_group_membersA | List a Google Group's basic metadata and member roster (Directory API). Resolves a group's actual membership directly, independent of any
specific message ever having been sent to it — unlike inferring
membership from Reports API delivery-event fanout (applicationName=gmail),
which only shows members who received one PARTICULAR message and
requires one to already exist to trace. Pair with gmail_message_trace
to deep-dive a specific member's mailbox once the roster is known, or
with group_delivery_policy to see why the group as a whole may not be
receiving mail at all. Read-only: only groups().get() and members().list() are issued
(Directory API), never a mutating call. Requires the
admin.directory.group.readonly and
admin.directory.group.member.readonly DWD scopes — granted PER
SERVICE ACCOUNT CLIENT ID in the Admin console, separately from every
other scope this server uses, and NOT on by default. The two calls are
independent: a tenant with only one of the two scopes granted still gets
that one section, with the other reported as {"error": ...} in its
place rather than failing the whole call — only when BOTH fail does the
tool return a single top-level error. Sets found: false (no group/members sections) when
group_email does not name any group in this domain — a normal,
expected answer for a bad/typo'd address, not an error. This
triggers both when BOTH calls agree with no error on either side, AND
when one call CONFIRMS not-found while the other independently failed
(its own error is then attached as group_lookup_error /
members_lookup_error) — a confirmed non-existence from one
independently-scoped call is stronger evidence than an unrelated
failure on the other, and must not be buried under it. Args:
group_email: The group's address.
domain: Configured [domain.*] section to route the lookup through.
Default: resolved from the address's suffix.
max_pages: Pagination cap for the member roster (Directory API hard
limit 200 members per page). Default 20 (≤4,000 members) —
raise for an unusually large group. capped: true means the
roster is NOT the complete one — either more pages existed
beyond this, or the member lookup failed outright (see
members_error); either way it must never be read as the
full membership, and an empty members list must not be
mistaken for a confirmed-empty group when capped is true. |
| drive_external_sharingA | Report Drive ACL grants to external targets and new link/public exposure. Counts grants whose target (target_user address, or target_domain
for domain-scoped grants) is outside the configured internal domains
(revocations are reported separately, not as exposure) and visibility
transitions into link/public access (people_with_link /
public_on_the_web, excluding a narrowing from public down to
link-only; Google's shared_externally is owner-domain relative, so
external-ness is judged by the target instead).
untargeted_external_transitions counts transitions into
shared_externally with no target address or domain (e.g. scope
became "anyone with the link" — target_domain: "all" — or an
unresolved target); it is a residual bucket, not a cross-check for
missed named grants, since domain-scoped grants are already classified
above. external_samples / exposure_samples / untargeted_samples
hold examples of each. A self-grant of owner on change_user_access
/change_acl_editors (a user creating their own new file — every
Form/Sheet/Doc submission does this) is excluded from
risky_visibility_events: it always reports a visibility transition
from a missing prior state, which is indistinguishable from a genuine
narrow-to-wide exposure event by visibility fields alone, but is not
exposure of anything pre-existing. When no target_user is named
(the change_acl_editors shape), a missing target alone is not proof
of creation — an admin bulk-transferring ownership of a pre-existing,
already-shared file (e.g. offboarding) looks the same — so this case is
corroborated against the event's own owner parameter (self-action
only if owner matches the actor); if owner itself is absent the
conservative default is to count it rather than risk dropping a real
ownership change. This exclusion is deliberately never applied to
change_document_access_scope/change_document_visibility
(see SELF_CREATION_GRANT_EVENTS) — those carry no target_user and
can legitimately report new_value: "owner" for a real transition, so
excluding them there would blind this tool's primary signal instead of
just removing creation noise. Each audit-relevant event name is
queried separately so the page budget is not consumed by view/edit noise
(change_document_visibility is fetched for its acl_events count
only — it duplicates change_document_access_scope on this API and
does not drive classification, so it cannot compensate if that probe's
own fetch fails). capped_events lists event names that exceeded
max_pages*1000 events; capped is also set when any probe's fetch
errored outright (see event_errors) — either way, treat that
domain's counts as lower bounds. Narrow hours or raise max_pages
for full coverage (term-time weekdays see >10k change_user_access
events/day). Shared-drive caveat: a file created INSIDE a shared drive emits
change_user_access events for each existing drive member (ACL
propagation), so an external member merely uploading files looks like
bulk external sharing here. When a finding's documents share one owner
that is a drive NAME rather than a user address, triage with
drive_doc_activity (per-document history: the "grants" coincide with
create/upload by the same actor) and
shared_drive_membership_changes (who added the members, and when). |
| drive_doc_activityA | Full audit history of one Drive document: owner, ACL changes, lifecycle. The triage companion to drive_external_sharing: a sharing finding names
a doc_id, and judging it requires who OWNS the document (an individual
user vs. a shared drive completely changes the risk read) and its grant
history over time. Uses a server-side doc_id filter, so the page budget
is spent on this one document only. owner / doc_title are taken from the document's own events (a
shared-drive-owned file reports the drive's name — not a user address — as
owner). events lists ACL and lifecycle events newest-first
(view/edit/download noise is counted in event_counts but not listed);
events_truncated is set when more matched than max_events.
The doc_id filter matches at the ACTIVITY level and one activity can
carry sibling events for OTHER documents (a multi-file share is one
activity with one event per file) — events whose own doc_id parameter
does not match (or is absent) are excluded from every output field and
tallied in sibling_events_skipped instead, so a bulk action cannot
contaminate this document's history or misattribute its owner.
An empty result means no events in the window for the queried tenant —
NOT proof the document does not exist (history older than the Reports API
retention, or a document living in a different tenant, looks the same).
Args:
doc_id: Drive document id (from a sharing finding's doc_id field).
days: How far back to scan (Reports API retains roughly 6 months).
domain: Restrict to one configured domain/tenant (default: all).
max_pages: Reports page cap per domain; capped=true means more existed.
max_events: Cap on listed events (counts are unaffected). |
| shared_drive_membership_changesA | Membership add/remove/role-change history across shared drives. Answers "who added this (external) member, and when" — the other half of
triaging a shared-drive sharing finding (see drive_doc_activity).
Membership changes are low-volume, so a plain window scan of the single
shared_drive_membership_change event works even over months. Each entry's drive is the shared drive's NAME as the audit log reports
it (the event's owner parameter — not an id, not a user address);
target_is_external classifies the affected member against the
configured internal domains. The Reports API cannot filter by drive
server-side, so drive_name is a client-side case-insensitive substring
match on that name — it narrows the listing, not the scan. An event whose
drive name is absent can neither match nor be ruled out; with
drive_name set such events are excluded from total/entries but
tallied in missing_drive_name so the drop is never silent (without
drive_name they are listed normally with drive: null). Args:
days: How far back to scan (Reports API retains roughly 6 months).
domain: Restrict to one configured domain/tenant (default: all).
drive_name: Only list entries whose drive name contains this substring.
max_pages: Reports page cap per domain; capped=true means more existed.
max_events: Cap on listed entries (total counts all matches). |
| daily_briefA | One-call security summary across all configured domains. Aggregates login_audit (account locks, suspicious logins) and
drive_external_sharing (external grants, new link exposure, and
untargeted_external_transitions — see that tool's docstring).
max_pages / samples are passed through to the drive scan;
max_pages defaults to the same page budget as the standalone tool,
so both report the same counters for the same window (samples
defaults lower here and only trims the example lists). Per-domain capped in the
summary means at least one underlying scan was partial — treat that
domain's counts as lower bounds (see capped_events in the drive
section for which probes were cut short). Synchronous: on a large tenant this can exceed a client's ~60s tool-call
timeout. If it does, use daily_brief_start + daily_brief_result
(same result, run in the background) or lower max_pages. |
| daily_brief_startA | Start a daily_brief in the background; returns immediately with a job_id. Use this instead of daily_brief when the synchronous call risks the client's ~60s
tool-call timeout (large tenants). Args mirror daily_brief. You MUST then poll
daily_brief_result(job_id) every few seconds until status is done (the full
daily_brief payload is under result) or error. On a config error returns
{"error": ...}; if too many jobs are already active returns
{"status": "rejected", ...}. |
| daily_brief_resultA | Fetch a daily_brief_start job by id. status is running (keep polling), done (result holds the full daily_brief
payload), error (error holds the exception type name — the message is omitted to
avoid leaking internal detail), or unknown (bad/expired id).
|