feat: Google Workspace MCP server integration with strict sandbox#113
feat: Google Workspace MCP server integration with strict sandbox#113
Conversation
Integrate @alanse/mcp-server-google-workspace (128 tools across Gmail, Calendar, Drive, Docs, Sheets, Slides) into IronCurtain using a credential-file rendezvous pattern. Core components: - gworkspace-credentials.ts: atomic write of access-token-only credential files (refresh_token intentionally omitted to prevent rotation races) - token-file-refresher.ts: proactive 5-minute interval refresher that writes fresh credential files before access tokens expire - Google OAuth scope picker expanded with gmail.modify, gmail.labels, documents, spreadsheets, presentations - Constitution principles for read-first/write-with-approval policy - Handwritten scenarios and policy engine tests for all tool categories - Argument roles for google-resource-id, email-address, email-body, calendar-datetime
Flip the google-workspace sandbox from a blocklist (denyRead specific dirs) to an allowlist (denyRead ~ with explicit allowRead). This prevents the MCP server from reading any user data under home. Changes: - Add allowRead support to SandboxFilesystemConfig, ResolvedSandboxParams, resolveSandboxConfig, and writeServerSettings - Add discoverNodePaths() to find node/npm install dirs that need re-allowing when home is denied (handles homebrew, nvm, volta, fnm, asdf) - Add rewriteServerSettings() to patch srt settings after OAuth setup adds the per-session credential directory - Strip NODE_OPTIONS from MCP server child env to prevent IDE debugger preloads from referencing blocked paths under home - Add google-workspace entry to mcp-servers.json with denyRead: ["~"] and allowRead for system dirs + OAuth credential injection - Add test-gworkspace.mjs smoke test script
…ipping - Use homedir() instead of manual HOME/USERPROFILE env var lookup in discoverNodePaths for consistency with the rest of the codebase - Use expandTilde() for tilde matching in denyRead checks instead of ad-hoc string comparisons - Scope NODE_OPTIONS='' to sandboxed servers only (unsandboxed servers don't need IDE preload stripping) - Consolidate two rewriteServerSettings calls into one for OAuth servers, combining node path and credential dir injection - Remove Homebrew paths from discoverNodePaths (not under ~, already readable) and /opt/homebrew from static allowRead in mcp-servers.json
There was a problem hiding this comment.
Pull request overview
Integrates the @alanse/mcp-server-google-workspace MCP server into IronCurtain with a strict sandbox profile and a proxy-side OAuth credential-file rendezvous + proactive refresh loop, along with policy/role/scenario updates and tests.
Changes:
- Add proxy-side OAuth credential file writing (
.gworkspace-credentials.jsonwithout refresh_token) plusTokenFileRefresherto refresh access tokens before expiry. - Extend sandbox integration to support
allowRead, dynamic node path discovery (discoverNodePaths()), and post-write settings patching (rewriteServerSettings()). - Add Google Workspace policy fixtures, argument roles, handwritten scenarios, scope registry updates, and new/updated tests + smoke-test script + design doc.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/token-file-refresher.test.ts | Unit tests for proactive token refresh loop. |
| test/gworkspace-credentials.test.ts | Unit tests for atomic credential-file writing + permissions + refresh_token omission. |
| test/sandbox-integration.test.ts | Adds coverage for allowRead, rewriteServerSettings, and discoverNodePaths. |
| test/policy-engine.test.ts | Adds policy evaluation tests for Google Workspace tools (allow/escalate/deny). |
| test/handwritten-scenarios.test.ts | Updates expected handwritten scenario count after adding Workspace scenarios. |
| test/fixtures/test-policy.ts | Adds compiled-policy rules + tool annotations + domain allowlist for google-workspace. |
| test/auth/google-scopes.test.ts | Updates scope registry expectations (more services, default semantics). |
| test/argument-roles.test.ts | Extends role registry tests for new Workspace roles and canonicalization behavior. |
| src/types/argument-roles.ts | Adds new Workspace argument roles and registry entries. |
| src/trusted-process/token-file-refresher.ts | Implements interval-based access token refresh + credential file rewrite. |
| src/trusted-process/gworkspace-credentials.ts | Implements atomic, 0o600 access-token-only credential file writer. |
| src/trusted-process/sandbox-integration.ts | Adds allowRead support, node path discovery, and settings rewrite helper. |
| src/trusted-process/mcp-proxy-server.ts | Injects OAuth env + creds dir, starts refreshers, patches sandbox settings, strips NODE_OPTIONS for sandboxed servers. |
| src/pipeline/handwritten-scenarios.ts | Adds handwritten Google Workspace scenarios (ground truth). |
| src/config/types.ts | Extends sandbox filesystem config with allowRead. |
| src/config/mcp-servers.json | Adds google-workspace server entry with strict sandbox + allowed domains. |
| src/config/constitution-user-base.md | Adds constitutional principles for Google Workspace access patterns. |
| src/auth/providers/google-scopes.ts | Adds additional Google scopes for Docs/Sheets/Slides and more Gmail scopes. |
| scripts/test-gworkspace.mjs | Adds smoke test spawning the server in srt and calling Gmail tools. |
| docs/designs/google-workspace-integration.md | Adds design doc for the credential-file rendezvous + sandbox strategy. |
| env: { | ||
| ...(process.env as Record<string, string>), | ||
| // Strip NODE_OPTIONS for sandboxed servers to prevent IDE debugger preloads | ||
| // (e.g., Cursor/VS Code) from referencing paths under ~ that denyRead blocks. | ||
| ...(resolved.sandboxed ? { NODE_OPTIONS: '' } : {}), | ||
| ...(config.env ?? {}), | ||
| ...serverCredentials, | ||
| ...oauthEnv, | ||
| }, |
| ...(config.env ?? {}), | ||
| ...serverCredentials, | ||
| ...oauthEnv, | ||
| }, |
| toolName: 'gmail_list_messages', | ||
| serverName: 'google-workspace', | ||
| comment: 'Lists Gmail messages matching a query.', |
| 'email-address', | ||
| { | ||
| description: 'Email address (recipient, attendee, share target)', | ||
| isResourceIdentifier: false, | ||
| category: 'opaque', | ||
| canonicalize: lowercase, | ||
| annotationGuidance: | ||
| 'Assign to arguments that are email addresses (recipients, attendees, share targets). ' + | ||
| 'Includes the "to" field on send/draft, "attendees" on calendar events, "email" on file sharing.', | ||
| serverNames: ['google-workspace'], | ||
| }, |
| // 5. Connect MCP client and call gmail_list_messages | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const client = new Client({ name: 'gws-test', version: '1.0.0' }); | ||
|
|
||
| try { | ||
| await client.connect(transport); | ||
| process.stderr.write('Connected to MCP server. Listing tools...\n'); | ||
|
|
||
| const { tools } = await client.listTools(); | ||
| process.stderr.write(`Server exposes ${tools.length} tools.\n`); | ||
|
|
||
| // Find the gmail_search_messages tool | ||
| const gmailSearch = tools.find((t) => t.name === 'gmail_search_messages'); | ||
| if (!gmailSearch) { | ||
| process.stderr.write('Tool gmail_search_messages not found. Available tools:\n'); | ||
| for (const t of tools.slice(0, 20)) { | ||
| process.stderr.write(` ${t.name}\n`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| process.stderr.write('Searching for 5 most recent emails...\n'); | ||
|
|
||
| const searchResult = await client.callTool({ | ||
| name: 'gmail_search_messages', | ||
| arguments: { query: '', maxResults: 5 }, |
…dress role - Redact oauthEnv (CLIENT_SECRET etc.) in stderr logging alongside serverCredentials to prevent unredacted secret leakage to session logs - Rename gmail_list_messages → gmail_search_messages in fixtures, scenarios, and tests to match the actual MCP server tool name - Fix smoke test comment to say gmail_search_messages (matching code) - Change email-address canonicalize from lowercase to identity since isResourceIdentifier: false means prepareToolArgs won't invoke it
…share-permission Remove google-resource-id and calendar-datetime argument roles — both are opaque with isResourceIdentifier: false, making them functionally identical to 'none' with no policy evaluation impact. Add share-permission role for drive_share_file's role argument, where the small set of known values (viewer/commenter/editor) can meaningfully drive policy decisions like restricting editor access.
There was a problem hiding this comment.
Pull request overview
Adds a sandboxed Google Workspace MCP server integration, including OAuth credential-file injection (access-token-only) managed by the trusted proxy process, plus policy/test updates to classify Google Workspace tools as read-only vs mutation operations.
Changes:
- Add Google Workspace server entry and strict sandboxing support with
allowRead(for re-allowing Node/version-manager paths underdenyRead: ["~"]). - Implement proxy-side OAuth token injection via per-session credential files and a periodic
TokenFileRefresher. - Extend policy fixtures/scenarios, argument roles, and tests for Google Workspace tool decisions and scope registry updates.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/trusted-process/mcp-proxy-server.ts |
Injects OAuth credentials for the google-workspace server, rewrites sandbox settings for creds/node paths, starts/stops token refreshers, and redacts OAuth env in logs. |
src/trusted-process/token-file-refresher.ts |
New interval-based refresher that proactively refreshes and rewrites token credential files before expiry. |
src/trusted-process/gworkspace-credentials.ts |
Writes access-token-only .gworkspace-credentials.json atomically with 0o600 permissions. |
src/trusted-process/sandbox-integration.ts |
Adds allowRead, plus helpers to discover Node install paths and rewrite existing srt settings files. |
src/config/mcp-servers.json |
Adds the google-workspace MCP server (npx-based) with strict sandbox settings and domain allowlist. |
src/config/types.ts |
Extends sandbox filesystem config with allowRead. |
src/types/argument-roles.ts |
Adds Google Workspace argument roles (email-address, email-body, share-permission). |
src/auth/providers/google-scopes.ts |
Adds additional Google scopes (Gmail modify/labels + Docs/Sheets/Slides). |
src/config/constitution-user-base.md |
Adds Google Workspace principles for read-first vs write-with-approval behavior. |
src/pipeline/handwritten-scenarios.ts |
Adds handwritten Google Workspace policy scenarios (allow read-only, escalate mutations, deny unknown). |
docs/designs/google-workspace-integration.md |
Design doc describing the credential-file rendezvous, sandbox strategy, and operational/security tradeoffs. |
scripts/test-gworkspace.mjs |
Smoke test script to spawn the server in srt, then list recent Gmail messages. |
test/token-file-refresher.test.ts |
Unit tests for refresh thresholding, scope propagation, error logging, and start/stop behavior. |
test/gworkspace-credentials.test.ts |
Unit tests for credential file format, permissions, atomic overwrite, and no refresh_token. |
test/sandbox-integration.test.ts |
Extends sandbox integration tests for allowRead, settings rewrite, and node path discovery. |
test/policy-engine.test.ts |
Adds policy engine tests validating allow/escalate/deny decisions for representative GWS tools. |
test/fixtures/test-policy.ts |
Adds test compiled-policy rules + tool annotations for google-workspace, plus domain allowlist entries. |
test/handwritten-scenarios.test.ts |
Updates expected handwritten scenario count for the newly added Workspace scenarios. |
test/auth/google-scopes.test.ts |
Updates scope registry tests for additional service groups and default-scope expectations. |
test/argument-roles.test.ts |
Updates registry-size expectations and adds tests for new Workspace roles. |
| this.intervalHandle = setInterval(() => { | ||
| void this.refreshIfNeeded(); | ||
| }, intervalMs); | ||
|
|
||
| // Ensure the interval doesn't keep the process alive | ||
| this.intervalHandle.unref(); | ||
| } |
| "google-workspace": { | ||
| "description": "Google Workspace tools (Gmail, Calendar, Drive, Docs, Sheets, Slides)", | ||
| "command": "npx", | ||
| "args": ["-y", "@alanse/mcp-server-google-workspace"], |
| */ | ||
| function addIfUnderHome(paths: Set<string>, dir: string, home: string): void { | ||
| if (home && dir.startsWith(home + '/')) { |
…ymlinks - Add in-flight guard to TokenFileRefresher to prevent concurrent refresh attempts when a slow network call overlaps the next interval - Pin @alanse/mcp-server-google-workspace to v1.0.2 for reproducibility - Resolve homedir() through realpath in discoverNodePaths so prefix comparisons work when home involves symlinks (e.g., macOS /var)
Remove git and google-workspace scenarios from handwritten-scenarios.ts — these are constitution-dependent and would conflict with user constitutions that make different policy choices for those servers. Handwritten scenarios constrain the LLM compiler, so only universal invariants (filesystem sandbox containment) belong here. Server-specific policy behavior is already tested in policy-engine.test.ts.
There was a problem hiding this comment.
Pull request overview
Integrates the Google Workspace MCP server into IronCurtain with an access-token-only credential-file rendezvous pattern, proactive token refreshing in the proxy process, and stricter sandboxing (notably denyRead: ["~"] plus explicit re-allows).
Changes:
- Add proxy-side OAuth token injection for
google-workspace, including per-session credential file writing and aTokenFileRefresher. - Extend sandbox settings to support
allowRead, plus runtime patching of settings and node path discovery fordenyRead: ["~"]setups. - Add/adjust policy fixtures, argument roles, constitution text, and tests for the new integration points.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/token-file-refresher.test.ts | Unit tests for proactive credential refresh behavior and lifecycle. |
| test/sandbox-integration.test.ts | Adds coverage for allowRead, rewriteServerSettings(), and discoverNodePaths(). |
| test/policy-engine.test.ts | Adds policy decision tests for key Google Workspace tools. |
| test/handwritten-scenarios.test.ts | Updates expected scenario count after pruning handwritten scenarios. |
| test/gworkspace-credentials.test.ts | Verifies credential file format, atomic writes, and permissions. |
| test/fixtures/test-policy.ts | Adds Google Workspace tool annotations, rules, and domain allowlist entries for tests. |
| test/auth/google-scopes.test.ts | Updates expectations for expanded Google scope registry and defaults behavior. |
| test/argument-roles.test.ts | Updates role registry tests for new Google Workspace-related roles. |
| src/types/argument-roles.ts | Adds new opaque roles (email-address, email-body, share-permission) with registry entries. |
| src/trusted-process/token-file-refresher.ts | Implements periodic, non-throwing refresh loop for credential files. |
| src/trusted-process/sandbox-integration.ts | Adds allowRead, node-path discovery, and settings rewrite utility. |
| src/trusted-process/mcp-proxy-server.ts | Adds OAuth-backed server setup (credential dir, env injection, refresher lifecycle) and NODE_OPTIONS stripping for sandboxed servers. |
| src/trusted-process/gworkspace-credentials.ts | Writes access-token-only .gworkspace-credentials.json atomically with 0o600 perms. |
| src/pipeline/handwritten-scenarios.ts | Narrows handwritten scenarios to universal invariants only. |
| src/config/types.ts | Extends sandbox filesystem config with allowRead. |
| src/config/mcp-servers.json | Adds google-workspace server entry with strict sandbox + network allowlist. |
| src/config/constitution-user-base.md | Adds Google Workspace principles (read-first, write-with-approval, etc.). |
| src/auth/providers/google-scopes.ts | Adds additional Gmail/Docs/Sheets/Slides scope entries. |
| scripts/test-gworkspace.mjs | Adds a smoke test script to run the server in an srt sandbox and exercise Gmail tools. |
| docs/designs/google-workspace-integration.md | Design doc for the integration approach, security model, and tradeoffs. |
| const home = process.env.HOME ?? ''; | ||
| if (!home) return; // skip if no HOME | ||
| const paths = discoverNodePaths(); | ||
| for (const p of paths) { | ||
| // All returned paths should be under home (since only those need allowRead) | ||
| // except macOS Homebrew paths which are system-wide | ||
| if (!p.startsWith('/opt/homebrew') && !p.startsWith('/usr/local')) { | ||
| expect(p.startsWith(home + '/')).toBe(true); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it('includes node installation prefix when node is under home', () => { | ||
| const home = process.env.HOME ?? ''; | ||
| if (!home || !process.execPath.startsWith(home + '/')) return; |
| const npxPath = process.env.NPX_PATH ?? 'npx'; | ||
|
|
||
| // srt -s <settings> -c "<command>" | ||
| // The -c flag runs the command string through a shell, so no escaping needed | ||
| // for the simple npx invocation. | ||
| const serverCommand = `${npxPath} -y @alanse/mcp-server-google-workspace@1.0.2`; | ||
|
|
| "network": { | ||
| "allowedDomains": [ | ||
| "googleapis.com", | ||
| "*.googleapis.com", | ||
| "accounts.google.com", | ||
| "oauth2.googleapis.com", | ||
| "registry.npmjs.org", | ||
| "*.npmjs.org" | ||
| ] | ||
| } |
| const sandboxConfig = serverConfig.sandbox ?? {}; | ||
| const fsConfig = sandboxConfig.filesystem ?? {}; | ||
| const networkConfig = sandboxConfig.network; | ||
|
|
||
| const allowWrite = buildAllowWrite(sessionSandboxDir, fsConfig.allowWrite ?? []); | ||
| const allowRead = fsConfig.allowRead ?? []; | ||
|
|
||
| const denyRead = fsConfig.denyRead ?? DEFAULT_DENY_READ; | ||
| const denyWrite = fsConfig.denyWrite ?? []; | ||
|
|
||
| const network = resolveNetworkConfig(networkConfig); | ||
|
|
||
| return { | ||
| sandboxed: true, | ||
| config: { allowWrite, denyRead, denyWrite, network }, | ||
| config: { allowWrite, allowRead, denyRead, denyWrite, network }, | ||
| }; |
- Expand tilde paths in allowRead during resolveSandboxConfig for consistency with allowWrite normalization (prevents silent failures if someone puts ~/.nvm in allowRead in mcp-servers.json) - Use realpath-resolved homedir in discoverNodePaths test to match the implementation and avoid flakiness on symlinked home dirs - Add _comment explaining npm registry domains in mcp-servers.json are needed for npx -y initial install
Summary
@alanse/mcp-server-google-workspace) with credential-file rendezvous pattern: IronCurtain writes access-token-only.gworkspace-credentials.jsonfiles (no refresh_token), with aTokenFileRefresherproactively refreshing before expirydenyRead: ["~"]with explicitallowRead) and dynamic node path discovery (discoverNodePaths()) that resolves nvm/volta/fnm/asdf installations for re-allowingrewriteServerSettings()to patch srt settings after OAuth credential directories are createdTest plan
scripts/test-gworkspace.mjs) successfully lists 5 recent emails through sandboxed MCP server~/.ssh)