Security — the threat model¶
This page says honestly what the project protects, and what it does not protect. No marketing promises: a tool that touches your emails deserves to be clear about this. Component details are in the architecture.
In plain terms, first¶
The goal: let an AI assistant access several Google accounts, locally, without trusting it by default. What that guarantees — and what it does not guarantee:
- ✅ Protected. A cooperative assistant (one that goes through the tool) cannot send an email, write outside the folders you have opened, touch a locked account, or grant itself access. A new account is read + drafts, zero sending.
- ⚠️ Not protected. An assistant with a free shell on your machine can bypass all of this by calling the Google tool directly (detailed below, under "what is not guaranteed"). Elicitation disciplines behavior; it does not remove the capability as long as credentials are readable on disk. The countermeasure: restrict the agent's shell (see the mitigation); the vault that closes this gap is planned (Phase 2.1).
In short: very solid against a cooperative agent, but not a prison against an adversarial agent with disk access. The rest of the page details each guarantee, phase by phase.
Trust surfaces¶
| Component | Role | Trust |
|---|---|---|
| Human | unlock, grant, policy, OAuth | Root of trust |
Admin 127.0.0.1:4877 |
Cockpit | Local process; no app auth |
bin/mag + scripts/policy-check.py |
CLI guardrails | Applied if you go through them |
gateway/ + bin/google-mcp |
MCP entry point | Same policy/lock; curated tools (no send) |
gws + ~/.config/gws-accounts/ |
Tokens + Google calls | Real capability to access data |
Phase 1 (current) — what is guaranteed¶
- Profile created via
mag add→ cautious policy (Drive zones empty, Gmail without sending, unlisted services denied). - Service missing from
policy.json→ default-deny (exceptauth/schema). - Locked profile → refused via
magand via MCP. - MCP tools: Gmail read + drafts + attachment (→
.downloads), Drive read (metadata + content) + create/copy/upload + update (content replacement, non-native files only) under zones; sharing read/write (drive_permissions_create/list/delete) — never public sharing (typefixed touser); no Gmail sending, permanent deletion, or ownership transfer (removed — dedicated PR not ready). - ⚠ Sharing (
drive_permissions_*) is only guarded by thedrive.share:truepolicy (a persistent flag), not by zones or by a session grant: onceshareis enabled, the agent can share any file it owns to any email (never public —type=user). Hardening to be decided (security review F2): a session grant to share. access_requestproposes unlock/grant without running them.- Touch ID (
mag strongauth): physical presence for unlock/grant (absolute paths/usr/bin/swift+/usr/bin/swiftc+ repo scripts — never via PATH). - Per-session rights (ADR-0007): each conversation carries a signed token; its capabilities (service × operation × resource) are isolated from other sessions and signed at each grant; effective rights = policy ∩ project manifest ∩ session capabilities; a tampered project manifest closes everything (anti-downgrade). TTL / revocation lifecycle (an MCP disconnect purges nothing). Remains cooperative until the vault is in place.
Phase 1 — what is not guaranteed¶
An agent with a free shell and filesystem access can still run:
and bypass lock, policy, audit log, and MCP. Elicitation controls cooperative
behavior; it does not remove the capability as long as the credentials are
usable by gws outside the gateway.
Recommended mitigation (clients with a shell: Claude Code, Cursor Agent):
- Supported path for Google data = MCP only (
bin/google-mcp). - Restrict / deny in bash permissions:
gws,GOOGLE_WORKSPACE_CLI_CONFIG_DIR=…, read/write of~/.config/gws-accounts/. - Do not put
gwsin the agent's PATH if possible; the human keepsmagfor admin.
Phase 2 A (current) — loopback broker¶
- MCP → gateway → broker (
127.0.0.1:4878, token~/.config/gws-accounts/.broker-token) →gws. - The broker auto-starts on the first MCP tool call if needed (
bin/google-broker). - The broker re-applies lock + policy before
gws.
Phase 2 A — what is not yet guaranteed¶
Credentials remain in ~/.config/gws-accounts/. An agent with a free shell
can still call gws directly. Mitigation: restrict the shell;
next step = vault (features/0003-vault-credentials-hors-perimetre-agent.md).
Email = identity metadata (outside the lock)¶
A profile's email stays readable even when locked — it is the only
metadata exposed (IAM diagnostic of setup_status, SECURITY.md). Since
ADR-0002, it comes from an
.email file written by the human mag add gesture (backfill: mag list,
admin) — never from a gws execution. Tested invariant: lock ⇒ zero
gws execution; no GOOGLE_WORKSPACE_CLI_CONFIG_DIR in gateway/
outside broker_server.py.
Content deposited on Drive (deposit directory)¶
drive_create(content=…) materializes the text in
~/.config/gws-accounts/.uploads/ (0700) for the duration of one gws --upload call:
this is the only way to pass media to gws, which otherwise refuses any
path outside its working directory (ADR-0003).
File in 0600, deleted in finally (on success and on failure) — exposure
comparable to usage.jsonl, which already logs command arguments.
Favorable side effect: gws now runs from this directory,
so its file sandbox is no longer the git repo. Drive zones
apply unchanged: parents stays in --json, the only place
policy-check.py reads to validate the destination.
Received files (download directory)¶
gmail_attachment_get materializes an attachment — third-party content,
potentially hostile — in ~/.config/gws-accounts/.downloads/ (0700),
the only possible destination (ADR-0006).
The tool accepts no destination path: only an indicative filename,
reduced to a sanitized base name (never a separator, never hidden,
never empty). Written with O_CREAT|O_EXCL (0600), numeric suffix if the name
already exists — never overwriting. Symmetry with the upload direction:
drive_upload refuses to read under GWSA_ROOT (tokens, credentials, policies),
except for .downloads (re-uploading a received attachment). The final path is
returned to the caller, who then decides on a possible cp — outside the gateway.
On the upload side, the source of drive_upload is restricted to an allowlist
(.downloads + the folders declared in <GWSA_ROOT>/.upload-roots
or GWSA_UPLOAD_ROOTS), not the whole disk: the LLM cannot read an arbitrary
path (e.g. ~/.ssh/id_rsa) and exfiltrate it to an active zone —
default-deny, only the human opens the readable folders (the .upload-roots file
is not writable by a tool) —
ADR-0006.
Phase 2.1 (planned) — vault¶
Credentials out of the agent's read reach; only the broker socket remains useful.
Phase 1 — legacy profiles¶
A profile without policy.json (created before this hardening) stays unfiltered
by the checker until you set one. To switch: "cautious" preset
in the admin, or copy gateway/default_policy.py.
Audit log¶
~/.config/gws-accounts/usage.jsonl — allowed calls (decision:ok), policy
refusals and lock refusals (decision:refus, reason:locked), on all three
paths (mag, broker, gateway fail-fast). client field via GWSA_CLIENT
(mcp, claude-code, cli, …). Successful calls additionally carry the
session_id and the service / operation / resource (per-session attribution).
Spoofable (GWSA_CLIENT): useful for debugging, not a strong identity.