Skip to content

feat(gmail): wire attachments into gmail.send (shared with createDraft) + optional ATTACHMENT_ALLOWED_ROOTS allowlist#392

Open
f-liva wants to merge 6 commits into
gemini-cli-extensions:mainfrom
f-liva:feat/gmail-send-attachments
Open

feat(gmail): wire attachments into gmail.send (shared with createDraft) + optional ATTACHMENT_ALLOWED_ROOTS allowlist#392
f-liva wants to merge 6 commits into
gemini-cli-extensions:mainfrom
f-liva:feat/gmail-send-attachments

Conversation

@f-liva

@f-liva f-liva commented Jun 11, 2026

Copy link
Copy Markdown

What

Wires the existing createDraft attachment support into gmail.send and
adds an optional ATTACHMENT_ALLOWED_ROOTS filesystem allowlist.

gmail.createDraft already accepts attachments: { filePath, filename?, mimeType? }[]
and builds a multipart/mixed message via MimeHelper.createMimeMessageWithAttachments.
gmail.send, however, ignored attachments entirely. This PR makes the two
compose paths consistent.

Changes

  • Move the attachments field into the shared emailComposeSchema so both
    gmail.send and gmail.createDraft accept it (single source of truth; the
    per-tool inline duplicate on createDraft is removed).
  • Extract the per-email attachment handling (absolute-path check, size cap,
    file read, MIME assembly) out of createDraft into a single
    buildComposeMimeMessage helper used by both send and createDraft, so the
    18MB size cap and validation cannot drift between the two paths.
  • Promote MAX_TOTAL_ATTACHMENT_SIZE_BYTES to a single exported constant in
    validation.ts consumed by both paths.
  • Add an optional ATTACHMENT_ALLOWED_ROOTS allowlist 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.ts with the send-path attachment behaviour and the
allowlist 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.

@google-cla

google-cla Bot commented Jun 11, 2026

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
const configuredRoots = raw.split(':').filter(Boolean);
const configuredRoots = raw.split(path.delimiter).filter(Boolean);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 003e2b6 — thanks.

Comment on lines +69 to +72
const real = realpathSync(path.resolve(filePath));
const ok = resolvedRoots.some(
(root) => real === root || real.startsWith(root + path.sep),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
  });

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 003e2b6 — thanks.


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}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the test suite is cross-platform and passes on Windows, use realPath.delimiter instead of a hardcoded colon ':' when joining allowed roots.

Suggested change
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}:${allowedDir}`;
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}${realPath.delimiter}${allowedDir}`;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 003e2b6 — thanks.

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}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the test suite is cross-platform and passes on Windows, use realPath.delimiter instead of a hardcoded colon ':' when joining allowed roots.

Suggested change
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}:${missingB}`;
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}${realPath.delimiter}${missingB}`;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 003e2b6 — thanks.

@f-liva

f-liva commented Jun 11, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it!

f-liva added 6 commits June 11, 2026 16:08
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.
@f-liva f-liva force-pushed the feat/gmail-send-attachments branch from 003e2b6 to 44ca4d2 Compare June 11, 2026 14:08
@f-liva f-liva marked this pull request as ready for review June 11, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant