feat(gmail): wire attachments into gmail.send (shared with createDraft) + optional ATTACHMENT_ALLOWED_ROOTS allowlist#392
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces support for file attachments in the gmail.send tool and refactors the attachment logic to share a unified MIME message builder (buildComposeMimeMessage) with gmail.createDraft. It also implements an optional filesystem allowlist gate (ATTACHMENT_ALLOWED_ROOTS) to restrict attachment paths to configured directories. Feedback from the review highlights critical cross-platform compatibility issues on Windows, specifically the use of hardcoded colon delimiters (':') for splitting paths (which should use path.delimiter) and potential bugs in the path containment logic when the configured root is the filesystem root (which can be resolved robustly using path.relative).
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return; | ||
| } | ||
|
|
||
| const configuredRoots = raw.split(':').filter(Boolean); |
There was a problem hiding this comment.
Using a hardcoded colon ':' to split ATTACHMENT_ALLOWED_ROOTS will break on Windows platforms, where absolute paths contain drive letters with colons (e.g., C:\path) and the standard path list delimiter is a semicolon ';'. Using path.delimiter ensures cross-platform compatibility.
| const configuredRoots = raw.split(':').filter(Boolean); | |
| const configuredRoots = raw.split(path.delimiter).filter(Boolean); |
| const real = realpathSync(path.resolve(filePath)); | ||
| const ok = resolvedRoots.some( | ||
| (root) => real === root || real.startsWith(root + path.sep), | ||
| ); |
There was a problem hiding this comment.
The current containment check real === root || real.startsWith(root + path.sep) has a bug when root is the filesystem root (e.g., / on Unix or C:\ on Windows). For example, if root is /, then root + path.sep becomes //. A path like /etc/passwd does not start with //, so it would be incorrectly rejected.
Using path.relative is a much more robust and platform-agnostic way to check if a path is contained within a directory. Additionally, wrapping realpathSync in a try-catch block allows us to throw a more descriptive error if the attachment file cannot be resolved.
let real: string;
try {
real = realpathSync(path.resolve(filePath));
} catch (err) {
throw new Error(
`Attachment path rejected: could not resolve real path for ${filePath}: ${err instanceof Error ? err.message : String(err)}`
);
}
const ok = resolvedRoots.some((root) => {
const relative = path.relative(root, real);
return !relative.startsWith('..') && !path.isAbsolute(relative);
});|
|
||
| it('skips a non-existent root with a warning while a valid second root still gates correctly', async () => { | ||
| const missingRoot = realPath.join(tmpRoot, 'does-not-exist'); | ||
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}:${allowedDir}`; |
There was a problem hiding this comment.
To ensure the test suite is cross-platform and passes on Windows, use realPath.delimiter instead of a hardcoded colon ':' when joining allowed roots.
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}:${allowedDir}`; | |
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}${realPath.delimiter}${allowedDir}`; |
| it('fails closed when every configured root is missing', async () => { | ||
| const missingA = realPath.join(tmpRoot, 'missing-a'); | ||
| const missingB = realPath.join(tmpRoot, 'missing-b'); | ||
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}:${missingB}`; |
There was a problem hiding this comment.
To ensure the test suite is cross-platform and passes on Windows, use realPath.delimiter instead of a hardcoded colon ':' when joining allowed roots.
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}:${missingB}`; | |
| process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}${realPath.delimiter}${missingB}`; |
|
@googlebot I signed it! |
Move the attachments field into the shared emailComposeSchema so both
gmail.send and gmail.createDraft accept {filePath, filename?, mimeType?}.
Extract the per-email attachment handling (absolute-path check, size cap,
file read, MIME assembly) into a single buildComposeMimeMessage helper used
by both, so the size cap and validation cannot drift between the two paths.
Add an optional ATTACHMENT_ALLOWED_ROOTS allowlist gate: when the env var is
set, each attachment path must resolve (after realpath, no symlink escape)
inside one of the configured roots; unset means no restriction so the default
behaviour stays generic. Missing roots are skipped with a warning; if every
configured root is missing the gate fails closed.
Promote MAX_TOTAL_ATTACHMENT_SIZE_BYTES to a single exported constant in
validation.ts consumed by both compose paths.
…S gate Pin the send-path attachment behaviour added to GmailService.send: in-allowlist send produces multipart, out-of-root and symlink-escape paths are rejected when the env is set (gate is active, not merely present), a missing root is skipped with a warning while a valid second root still gates, all-missing roots fail closed, the shared size cap rejects oversize before read, attachments omitted falls back to the plain message, and an unset env applies no restriction. The root package.json prepare script (npm run build -> esbuild) already builds dist/ on install-from-git, so no script change is needed here.
npm refuses to run a workspaces build for a global package, so the previous 'prepare: npm run build' (which runs 'npm run build --workspaces') failed during 'npm install -g git+...': the bundle was never built and the gemini-workspace-server bin could not require dist/index.js. Replace prepare with scripts/prepare-build.js, which invokes esbuild directly in workspace-server/ (no npm workspaces). It falls back to a prebuilt dist/ when esbuild is unavailable and only errors when neither a toolchain nor a prebuilt artifact exists.
npm does not make esbuild resolvable to the root prepare script when preparing a git dependency for a global install, so the prepare-time build cannot run in that environment. Commit the prebuilt workspace-server bundle (minified, no sourcemaps) so 'npm install -g git+...#<sha>' has a runnable artifact and prepare-build.js takes its prebuilt-dist branch instead of trying to build. This is the pinned-branch deploy artifact; local development still rebuilds dist/ via npm run build.
…master key, logs) The server resolves its encrypted token, master key, and log directory against PROJECT_ROOT (the extension checkout). When the package is installed in a read-only location (e.g. a system-wide install owned by root while the server runs as an unprivileged user), those writes fail with EACCES. WORKSPACE_STATE_DIR points mutable state at a writable directory; the default (PROJECT_ROOT) is unchanged.
…, path.relative containment, resolve-error message
- Split ATTACHMENT_ALLOWED_ROOTS on path.delimiter (':' POSIX, ';' Windows)
so Windows drive-letter paths are not broken by the colon split.
- Containment check via path.relative instead of startsWith(root + sep),
fixing the filesystem-root edge case ('/' or 'C:\') and staying
platform-agnostic.
- Wrap realpathSync(filePath) in try/catch for a descriptive rejection
when the attachment path cannot be resolved.
- Tests join roots with path.delimiter.
003e2b6 to
44ca4d2
Compare
What
Wires the existing
createDraftattachment support intogmail.sendandadds an optional
ATTACHMENT_ALLOWED_ROOTSfilesystem allowlist.gmail.createDraftalready acceptsattachments: { filePath, filename?, mimeType? }[]and builds a
multipart/mixedmessage viaMimeHelper.createMimeMessageWithAttachments.gmail.send, however, ignored attachments entirely. This PR makes the twocompose paths consistent.
Changes
attachmentsfield into the sharedemailComposeSchemaso bothgmail.sendandgmail.createDraftaccept it (single source of truth; theper-tool inline duplicate on
createDraftis removed).file read, MIME assembly) out of
createDraftinto a singlebuildComposeMimeMessagehelper used by bothsendandcreateDraft, so the18MB size cap and validation cannot drift between the two paths.
MAX_TOTAL_ATTACHMENT_SIZE_BYTESto a single exported constant invalidation.tsconsumed by both paths.ATTACHMENT_ALLOWED_ROOTSallowlist gate(
utils/allowed-roots.ts). When the env var is set (colon-separated roots),each attachment path must resolve — after
realpath, so no symlink escape —inside one of the roots; a sibling directory with a shared name prefix does
not pass. When unset, no restriction is applied, so the default behaviour
stays generic. Missing roots are skipped with a warning; if every configured
root is missing the gate fails closed.
Tests
Extends
GmailService.test.tswith the send-path attachment behaviour and theallowlist gate: in-allowlist send produces a multipart message when the env is
set (gate active but permissive), out-of-root and symlink-escape paths are
rejected, a missing root is skipped while a valid second root still gates,
all-missing roots fail closed, the size cap rejects oversize before read, and an
unset env applies no restriction. Full suite: green.
Notes
No new runtime dependencies. The no-attachment send path is unchanged. The
allowlist is purely opt-in so this stays a superset of the current behaviour.