Please report it privately, not in a public issue — a public report on a tool that holds domain-wide admin credentials is itself a risk to everyone running it.
Use GitHub's private reporting on this repository: Security → Report a vulnerability (direct link). If that page is not available to you, open a public issue containing no details — just asking for a private channel — and the maintainer will follow up.
Include what you did, what happened, and what you expected. A proof of concept helps enormously. This is a small volunteer project: expect a reply in days, not hours.
GamGUI is a local, single-operator desktop app. It runs a FastAPI server bound to 127.0.0.1
on a random port, gated by a per-launch token, and displays it in a native WKWebView window. (Without
pywebview it can instead be opened in a normal browser — a developer fallback with a known weakness,
below; the packaged .app never uses it.) There is no hosted service, no multi-tenancy, and no
remote users. Nothing is sent anywhere except to Google, by the bundled gam binary — and, when a
signature preview renders, the image requests its HTML names (see Known limitations).
The stakes are nonetheless high, because of what the app can reach:
oauth2service.jsoncan impersonate any user in the domain, andoauth2.txtis effectively an administrator password. Anything that exposes those, or that causesgamto run a command the operator did not intend, is serious.
In scope — the adversaries this project actually defends against:
- another local process running as the same user, reading credentials at rest or reaching the loopback server (within the limits under Known limitations);
- hostile or malformed data returned by Google (display names, signatures, calendar summaries,
event titles, group descriptions) flowing into HTML, CSV, or a
gamargument list; - supply chain — a tampered
gambinary, or a malicious dependency; - another page in the operator's browser reaching
127.0.0.1(CSRF / DNS rebinding).
Out of scope — these are not defects here, and reports about them will be closed:
- no rate limiting, no account lockout, no password policy;
- no role-based access control or multi-user authorization (there is exactly one operator);
- no TLS on the loopback socket;
- anything requiring an attacker who already has root, or the ability to modify the app bundle.
These are deliberate and load-bearing. A change that breaks one is a bug, and several have tests guarding them:
- Credentials live in the macOS Keychain. GAM's plaintext files are materialized into a
0700directory (files0600) only for the duration of a singlegamcall, then wiped — including via anatexithook and an owner-PID marker, so quitting mid-call does not strand them on disk; a quit that cancels a call in flight also kills itsgamprocess. gamis never invoked through a shell. Every invocation is an explicit argv list; operator input is always a single list element and is never string-interpolated into a command.- The launch environment can't steer
gam. It inherits only an allowlisted set of variables (noDYLD_*, noPYTHON*); the packaged app always runs its bundledgam, ignoring theGAMGUI_GAM_BINARYdevelopment override; and the app andgamare signed with the hardened runtime, so dyld ignores injectedDYLD_*variables. - Every mutation goes through the guard and is audited.
guard.evaluate()classifies risk and resolves the concrete affected set for a preview; for a destructive change, or any change to many targets, the route that applies it re-checks the posted confirmation withguard.enforce()before any write, so a POST that skips the confirm step writes nothing (a tripwire posts to every route, each either gated or exempt with a stated reason). By policy (core/guard.py) a single-target, low-risk change runs on one click: a delegate, a signature, an auto-reply, a calendar share, and the Builder's export of a read to a Google Sheet in a named user's Drive. An account delete needs the exact address typed on every path; a confirm step that posts the page's form runs the values its preview held under a single-use token, so a form edited after the preview writes nothing; and the write is then appended to a local audit log — as failed and "interrupted" if quitting cut it off mid-call. - Only read-only commands can become runnable automatically. The Builder promotes
grammar-derived commands to runnable only when they are confidently read-only; every write must
be hand-curated. Anything uncertain stays inert. The reads whose output is itself a secret or a
file stay runnable but every run is audited (
sensitive_read: the command and the target, never the output), as a Builder sequence step too, and so is downloading such a result as CSV (sensitive_csv_export, with the row count). Exactly two kinds: 2-Step Verification backup codes and Chrome browser enrollment tokens (show/print backupcodes,show/print browsertokens, named), and every read with GAM's download verbget, by rule — today a user's Drive file or Doc, their Keep note attachments, ChromeOS device files, and user and contact photos (get drivefile,document,noteattachments,devicefile,photo,profilephoto,contactphotos). Other reads of a user's content (a mailbox search, Chat messages, Keep notes listed as text) are not audited: reads are open by design. Exporting any read to a Google Sheet is audited as the write it is. - The loopback server rejects cross-origin callers and foreign hosts. Cookies are not
port-scoped, so a token cookie alone would let any page on another
127.0.0.1port drive the app; and every request, even the health check, must carry aHostof127.0.0.1:<port>orlocalhost:<port>, so a DNS-rebound page can't reach it. - The page runs no inline script. Every response carries a Content-Security-Policy with
script-src 'self'and no'unsafe-inline'or'unsafe-eval': the app's scripts are same-origin files, no template has an inline<script>oron*=handler (a test scans for them), and htmx's eval features are off. So markup that directory data smuggled into a page would render but not run.style-srcstill allows inline styles — signature HTML is styled inline, as email HTML must be, and its preview is a sandboxedsrcdocframe that inherits the page's policy — andimg-srcallows anyhttps:image, for the logos in those signatures. - The vendored
gambinary is checksum-pinned and verified fail-closed. An asset with no committed pin is refused, not installed.scripts/bump_gam.pywrites a new pin only aftergh attestation verifyshows the asset was built by GAM-team/GAM's release workflow (build.yml, frommain, on a GitHub-hosted runner) — not by any other workflow in that repo. - The
.app's Python dependencies are hash-locked.scripts/build_app.shinstalls them, in a fresh virtualenv, only fromrequirements/app.txtwithpip install --require-hashes: every file, and the build backend of the one package that ships only as source, must match a committed SHA-256. CI installsrequirements/dev.txtthe same way, from wheels only, andmake setup— the from-source venvmake runstarts the app from — installs both locks with--require-hashes. The pip that runs these installs is itself locked (requirements/pip.txt). - The UI's CSS is built ahead of time, by a pinned tool.
scripts/build_css.shruns the Tailwind standalone CLI only if it matches the SHA-256 committed for that platform inscripts/tailwind_checksums.txt(no pin, no run), and the unminified output is committed, so a change to it shows up in review. The page loads only same-origin files: that CSS, the bundled fonts (static/fonts, OFL, SHA-256s inSHA256SUMS), the app's own scripts and the SRI-pinned htmx. Nothing butgam's own calls leaves the machine (font-src 'self'). - Every GitHub Action is pinned by commit SHA. A tag can be moved to new code by whoever
controls the action's repository, and the release-watch job holds a token that can push a branch
and open a PR.
tests/test_workflow_safety.pyfails anyuses:pinned by tag; Dependabot bumps the SHA and its version comment together.
Accepted and documented rather than fixed; reports that only restate these will be closed.
- Browser mode shares the session cookie with every other
127.0.0.1port. Cookies are not port-scoped, so while GamGUI runs in a normal browser, any other local web server that browser visits receives the token cookie and can then drive GamGUI from outside the browser. Browser mode is a developer fallback for when pywebview is not installed, and it prints a warning saying so; the packaged.appbundles pywebview and refuses to fall back. Use the native window for real work — its WKWebView keeps a cookie store of its own. - During a
gamcall, the credentials are readable by your other processes. The0700directory keeps other users out, not other processes running as you: for the length of the call, any same-user process can read the plaintext files. The wipe keeps that window short; it does not close it. The real boundary against same-user code is the Keychain item's access control, which asks before any other app reads the credentials at rest. - Previewing a user's current signature can tell a remote host that you looked. The CSP keeps
img-src 'self' https: data:rather than'self'(gamgui/web/server.py), and the sandboxedsrcdocframe that shows a signature read from Gmail (_sig_current.html) inherits it. Anyhttps:image in that signature is fetched when the preview renders, so a tracking image learns the operator's network address and the time. No script runs in the frame, and no Referer is sent (Referrer-Policy: no-referrer). The Signatures screen's template preview (_sig_preview.html) is the same kind of frame and loads its images the same way. Clampingimg-srcwas declined because Gmail accepts only public HTTPS images in a signature (Hosting signature images). With the clamp, every real logo would be missing and the preview would quietly misrepresent what recipients see. Revisit this if the preview gains an explicit "load remote images" step, or if Gmail starts accepting inline images.
GamGUI runs on exactly two interpreters, both of which it ships: the one frozen into the .app (the
Python that built .venv — python.org 3.14.x today; scripts/build_app.sh makes its build venv from
it) and the one inside the bundled gam binary (GAM-team's build; gam version prints it). Other
Pythons on the Mac — Apple's /usr/bin/python3 (3.9, from the command-line tools), an older
python.org install — are never used, so a machine-wide scanner's CPython CVEs for them don't touch
GamGUI (triaged 2026-09-25: none of a 16-CVE report applied to 3.14.6 or GAM's 3.14.7). To check a
CPython CVE: .venv/bin/python --version and gam version, then the CVE's fixed versions. After a
CPython security release, rebuild .venv from the new python.org 3.14.x (make setup) and run
make app; GAM's own interpreter moves with a GAM bump.
- Destructive operations are guarded, but a guard cannot prove that a given GAM command does what you expect against your domain. Check Live verification status and rehearse anything unproven on a throwaway user, event, or calendar first.
- Account deletion is reversible only within Google's ~20-day window.
- There are no released builds: build and run it yourself. A build is signed for your own Mac (a local certificate or ad hoc), not notarized for distribution to other Macs.
- GamGUI is provided as-is under the MIT License, with no warranty. You are responsible for what you run against your own tenant.