Security Architecture¶
Overview¶
┌──────────┐ ┌───────────────┐ ┌─────────────────────┐
│ opencode │ STDIO │ MCP Server │ HTTPS │ Apps Script Web App │
│ (agent) │◄──────►│ (local tsx) │◄──────►│ script.google.com │
└──────────┘ └───────────────┘ └─────────────────────┘
│
Runs as USER_DEPLOYING
Google identity auto-applied
The security model rests on three layers: token authentication (who can call the proxy), identity delegation (whose permissions are used), and transport security (how data moves).
Token Model¶
Every Apps Script web app proxy is protected by a bearer token stored in Apps Script Script Properties. The setup script creates an untracked BootstrapSecret.gs file for each service, and bootstrap requires that setup key in a JSON POST body.
The primary token is:
- Generated on first successful bootstrap with
Utilities.getUuid()plus a second UUID with dashes removed. - Stored as
PROXY_AUTH_TOKENinPropertiesService.getScriptProperties(). - Returned exactly once after the caller proves possession of the bootstrap setup key.
- Disabled for repeat bootstrap by setting
PROXY_BOOTSTRAPPED=true.
// Auth.gs - token generation
function generateToken_() {
return Utilities.getUuid() + Utilities.getUuid().replace(/-/g, '')
}
The bootstrap flow:
scripts/setup.shwrites an untrackedBootstrapSecret.gscontainingBOOTSTRAP_SETUP_SECRET.- User deploys the Apps Script web app (manual GUI step).
- Setup calls
POST /execwith{"setupKey":"<bootstrap-setup-key>"}. - The proxy validates the setup key in constant time, generates the token, stores it, marks bootstrap complete, and returns the token.
- The user stores the token as an environment variable such as
GOOGLE_WORKSPACE_DRIVE_PROXY_TOKEN.
If bootstrap was consumed before the token reached .env, setup can call the same endpoint with {"setupKey":"<bootstrap-setup-key>","rotate":true}. Rotation validates the setup key, deletes the previous primary token, generates a replacement, marks bootstrap complete again, and returns the new token once.
The primary token's default authorization classes in Auth.gs (DEFAULT_AUTH_TOKEN_CLASSES) are the full set: read,draft,write,destructive,share,send. This matches the deployed bootstrap default — docs must not claim a read,draft-only default. Operators who want a narrower primary token set PROXY_AUTH_TOKEN_CLASSES in Script Properties, or configure class-scoped tokens (PROXY_WRITE_TOKEN, PROXY_SEND_TOKEN, PROXY_SHARE_TOKEN, PROXY_DESTRUCTIVE_TOKEN, PROXY_ADMIN_TOKEN). MCP servers and the wslite CLI prefer matching environment variables such as GOOGLE_WORKSPACE_DRIVE_PROXY_WRITE_TOKEN or GOOGLE_WORKSPACE_GMAIL_PROXY_SEND_TOKEN for higher-risk actions, then fall back to GOOGLE_WORKSPACE_<SERVICE>_PROXY_ADMIN_TOKEN, then the primary GOOGLE_WORKSPACE_<SERVICE>_PROXY_TOKEN.
Token Transmission¶
Normal proxy calls accept the token only in the JSON request body:
The MCP servers read tokens from environment variables and send the selected token in that body field. Read actions can use GOOGLE_WORKSPACE_<SERVICE>_PROXY_READ_TOKEN; write, send, share, destructive, and admin actions use their matching class token when configured. Authorization headers and query parameters are not part of the normal proxy request contract.
Rate Limiting¶
Each proxy applies a weighted 100 units per minute rate limit using CacheService.getScriptCache() and a script lock:
function rateLimitWithKey_(key, maxRequests, weight) {
const count = parseInt(cache.get(key) || '0', 10)
const requestWeight = Math.max(1, Math.min(Number(weight) || 1, maxRequests || 100))
if (count + requestWeight > (maxRequests || 100)) return true
cache.put(key, String(count + requestWeight), 60)
return false
}
- The rate limit counter is keyed by authenticated token kind, or by an invalid-token fingerprint for auth failures.
- Read actions cost 1 unit, write actions cost 3, send/share actions cost 6, destructive actions cost 8, and batch cost is the sum of child action weights capped at 100.
- Failed authentication attempts have a separate 20/minute throttle.
- When the limit is hit, the proxy returns
RATE_LIMITEDwith the message "Too many requests. Try again in 60 seconds." - The MCP server surface does not retry automatically — it surfaces the error to the agent.
ID Validation¶
All resource IDs passed to proxy actions are validated with regex patterns before any API call is made:
| Service | Pattern | Example Valid ID |
|---|---|---|
| Drive files/folders | ^[a-zA-Z0-9_-]+$ | <drive-file-id> |
| Sheets spreadsheets | ^[a-zA-Z0-9_-]+$ | <spreadsheet-id> |
| Slides presentations | ^[a-zA-Z0-9_-]+$ | <presentation-id> |
| Docs documents | ^[a-zA-Z0-9_-]+$ | <document-id> |
| Gmail message IDs | z.string().min(1) | 18a1b2c3d4e5f6g7 |
| Gmail thread IDs | z.string().min(1) | 18a1b2c3d4e5f6g7 |
| Calendar event IDs | z.string().min(1) | _60q30c1g6... |
| Tasks IDs | ^[a-zA-Z0-9_@.-]+$ | <tasklist-id> / <task-id> |
| Forms IDs | ^[a-zA-Z0-9_-]+$ | <form-id> / <spreadsheet-id> |
| Email addresses | Zod email() validator | user@domain.com |
Validation happens at both layers: the MCP server validates with Zod schemas before sending the request, and the Apps Script proxy re-validates IDs server-side before calling Google APIs.
No OAuth Per Call¶
This is the key architectural decision. The Apps Script web app is deployed as:
- Execute as: Me (
USER_DEPLOYING) - Access: Anyone (anonymous)
This means:
- No OAuth dance per API call. There are no refresh tokens, no consent screens, no token expiry.
- Google identity is automatic. The proxy runs under the identity of the user who deployed it. All API calls use that user's permissions.
- No service accounts. No service account JSON files, no domain-wide delegation, no IAM roles to configure.
- No third-party OAuth app verification. Since the proxy is a web app you deploy yourself, there's nothing for Google to review.
The trade-off is that the proxy can only act on behalf of one user — the deploying user. This is ideal for personal productivity automation but not for multi-user SaaS scenarios.
HTTPS Transport Only¶
All MCP server → proxy communication happens over HTTPS to script.google.com. Google's infrastructure terminates TLS and provides:
- Certificate validation and chain of trust
- HSTS enforcement
- Protection against downgrade attacks
There is no plain HTTP fallback. The MCP servers are hardcoded to use https:// URLs.
Request Flow Summary¶
1. opencode agent invokes an MCP tool (e.g., drive_search_files)
2. MCP server validates parameters with Zod schemas
3. MCP server constructs JSON payload: { action, params, token }
4. MCP server POSTs to proxy URL over HTTPS
5. Proxy validates the JSON-body token and establishes its authorization classes
6. Proxy checks weighted rate limits (CacheService, 100 units/min)
7. Proxy enforces action policy and confirmation gates
8. Proxy validates resource IDs and high-risk inputs
9. Proxy executes the action using Apps Script built-in or Advanced services (DriveApp, GmailApp, Tasks, FormApp, etc.)
10. Proxy returns JSON response
11. MCP server formats response for STDIO/JSON-RPC
What the Token Does NOT Protect¶
The token authenticates that the caller knows the shared secret. It does not:
- Provide per-user or per-agent identity (there is one token set per deployment).
- Encrypt request/response payloads (HTTPS handles transport encryption).
- Sign or provide non-repudiation for individual operations.
- Replace Google's own API authorization — the proxy uses
USER_DEPLOYINGidentity.
If your token is compromised, rotate it immediately (see Operations: Security).