From 3f8aa2fc58ee125a92df54682b6f7eed78e5c68a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 15:32:41 -0700 Subject: [PATCH 01/16] Give hey mcp its own sign-in, and send a draft only on the person's confirmation hey auth login --mcp signs hey mcp in as HEY MCP (local) (haystack#9041) on read write send: by the scoped device flow where HEY serves it (#9085), and by loopback otherwise. The credential is stored apart from the CLI's, refreshes and revokes as the local client, reads the scope HEY granted, and HEY_TOKEN never stands in for it. hey mcp serves on it alone and refuses without it. hey_send's send_draft delivers a draft after the person confirms that exact email through MCP elicitation (sending-safety D2-D6): digest-bound, single use, never retried, with the recipient caps and the exposure window, and its state in a locked, fsynced file. A host without elicitation gets the draft's link instead. --- .surface | 4 + README.md | 4 +- docs/agents.md | 72 ++- docs/cli.md | 10 + internal/auth/auth.go | 127 +++- internal/auth/device.go | 221 +++++++ internal/auth/mcp.go | 174 ++++++ internal/auth/mcp_test.go | 441 ++++++++++++++ internal/auth/oauth.go | 13 +- internal/auth/store.go | 15 + internal/cmd/auth.go | 43 +- internal/cmd/help_topics.go | 3 +- internal/cmd/mcp.go | 102 +++- internal/cmd/mcp_auth.go | 172 ++++++ internal/cmd/mcp_auth_test.go | 135 +++++ internal/cmd/mcp_test.go | 208 ++++++- internal/cmd/root.go | 5 + internal/cmd/sdk.go | 7 - internal/config/mcp.go | 83 +++ internal/config/mcp_test.go | 43 ++ internal/mcpserver/dispatch.go | 10 + internal/mcpserver/domains.go | 2 +- internal/mcpserver/drafts.go | 18 +- internal/mcpserver/exposure.go | 72 +++ internal/mcpserver/send.go | 668 +++++++++++++++++++++ internal/mcpserver/send_test.go | 994 +++++++++++++++++++++++++++++++ internal/mcpserver/sendbody.go | 259 ++++++++ internal/mcpserver/sendprompt.go | 158 +++++ internal/mcpserver/sendstate.go | 386 ++++++++++++ internal/mcpserver/server.go | 42 +- 30 files changed, 4395 insertions(+), 96 deletions(-) create mode 100644 internal/auth/device.go create mode 100644 internal/auth/mcp.go create mode 100644 internal/auth/mcp_test.go create mode 100644 internal/cmd/mcp_auth.go create mode 100644 internal/cmd/mcp_auth_test.go create mode 100644 internal/config/mcp.go create mode 100644 internal/config/mcp_test.go create mode 100644 internal/mcpserver/exposure.go create mode 100644 internal/mcpserver/send.go create mode 100644 internal/mcpserver/send_test.go create mode 100644 internal/mcpserver/sendbody.go create mode 100644 internal/mcpserver/sendprompt.go create mode 100644 internal/mcpserver/sendstate.go diff --git a/.surface b/.surface index d0359079d..51c03c382 100644 --- a/.surface +++ b/.surface @@ -26,9 +26,11 @@ hey attachment save --output hey auth hey auth login hey auth login --cookie +hey auth login --mcp hey auth login --no-browser hey auth login --token hey auth logout +hey auth logout --mcp hey auth refresh hey auth status hey auth token @@ -281,9 +283,11 @@ hey label view --limit hey label view --page hey login hey login --cookie +hey login --mcp hey login --no-browser hey login --token hey logout +hey logout --mcp hey mcp hey mcp --domains hey mcp --read-only diff --git a/README.md b/README.md index ed8ec70e9..a2ac16dd6 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,10 @@ The setup wizard connects the agents it finds. To do it yourself: ```bash hey setup claude # skill + the hey@37signals plugin for Claude Code hey setup codex # skill for Codex -claude mcp add hey -- hey mcp # HEY as MCP tools, on your signed-in account +hey auth login --mcp && claude mcp add hey -- hey mcp # HEY as MCP tools, on hey mcp's own sign-in ``` -`hey mcp` saves drafts and never sends mail. You send a draft from HEY or with `hey draft send `. +`hey mcp` saves drafts, and sends one only when you confirm that email in your MCP client. ## Docs diff --git a/docs/agents.md b/docs/agents.md index 5fc31f34c..5dba84127 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,16 +41,39 @@ is never overwritten or claimed. `hey doctor` flags an unmanaged baseline and ho ## MCP server `hey mcp` runs an MCP (Model Context Protocol) server on stdin/stdout, serving HEY -boxes, search, threads, contacts, todos, calendars, and your identity as tools -backed by your signed-in account — the same keychain-stored credentials every other command uses. -Register it with any MCP client as a stdio server: +boxes, search, threads, contacts, todos, calendars, and your identity as tools. +Sign it in once, then register it with any MCP client as a stdio server: ```bash +hey auth login --mcp # hey mcp's own sign-in (read, write and send) claude mcp add hey -- hey mcp # Claude Code hey mcp --read-only # serve only read-only actions hey mcp --domains boxes,search # narrow the served tool surface ``` +### Its own sign-in + +`hey mcp` doesn't use the CLI's credential. `hey auth login --mcp` signs it in as its own +app, "HEY MCP (local)" in HEY's Connected Apps, asking to read, write and send but nothing +more: a model driving `hey mcp` can't reach your settings, forwarding, trash or HEY World, +which the CLI's own sign-in can. The credential is kept apart from the CLI's, in its own +keychain item (or its own entry in `credentials.json` when there is no keychain), and it +refreshes as that app. `HEY_TOKEN` never stands in for it. Without it, `hey mcp` exits and +says to run `hey auth login --mcp`; it doesn't fall back to the CLI's credential, even +read-only, because that one holds full access and `--read-only` is hey mcp's own rule, +not HEY's. + +The sign-in uses a device code where HEY offers one (it prints a link and a code to check), +and the browser otherwise. If you uncheck sending on HEY's page, `hey mcp` saves drafts +but serves no way to send them; sign in again to allow it. Without write it serves only +reads. `hey auth status` shows both sign-ins, and `hey auth logout --mcp` revokes hey mcp's +at HEY and removes it, leaving the CLI signed in. + +The app's client id comes from `HEY_MCP_CLIENT_ID`, or from `mcp_client_ids` in +`~/.config/hey-cli/config.json`, keyed by HEY origin +(`{"mcp_client_ids": {"https://app.hey.com": "..."}}`). A repository's `.hey/config.json` +can't set it. + Each domain is one gateway tool (`hey_boxes`, `hey_search`, `hey_threads`, `hey_contacts`, `hey_todos`, `hey_calendar`, `hey_identity`) dispatching actions derived from the HEY SDK's API model; call an action named `describe` for any @@ -64,10 +87,10 @@ the caller rather than risking a duplicate delivery, so retry a failed write yourself once you know it did not land. Logs go to stderr — stdout carries the MCP wire protocol. -### Drafts, never sends +### Drafts first, sends confirmed -`hey mcp` never delivers mail. `create_message`, `create_reply` and `update_message`, -the three actions that can send on HEY's API, always save a draft: the entry's status is +`create_message`, `create_reply` and `update_message`, the three actions that can send +on HEY's API, always save a draft: the entry's status is forced to `drafted`, and the entry takes only its recipients (`entry.addressed`). A call that asks to send (any other `entry.status`), schedule (`entry.scheduled_delivery` and its date and hour) or triage the thread in the same request (`mark_done`, `pop`, @@ -79,8 +102,35 @@ mail may have been sent, so check HEY before trying again. A save that fails wit answer from HEY (a dropped connection, a timeout, a 5xx) may still have been saved, and its error says to check Drafts before retrying. -There is no send action yet. A person reviews the draft and sends it from HEY, or with -`hey draft send `. Earlier releases delivered mail when a call omitted -`entry.status`. An MCP client that relied on that now gets a draft. `describe` for these -actions serves the draft-only schema, and the `hey_threads` tool description says the same. -`--read-only` still drops every write action, drafts included. +Delivery is one action in a tool of its own: `hey_send`'s `send_draft`, with the draft's +id. Each call asks you to confirm the email in your MCP client (an MCP elicitation), +showing the sending address, To, Cc and Bcc (a dash when empty), the subject, attachments +and the body's first 1,500 characters, with every control and invisible character +escaped so a display name can't forge a line. Recipients you hadn't emailed before +`hey mcp` was signed in are marked new. What you approve is what goes: the draft is +fetched again after you answer, and if its recipients, sender, subject, body or +attachments changed it isn't sent. Each draft is sent through `hey mcp` once; another +attempt in the next 24 hours, even after a failure, is refused, and the send is never +retried, so an unclear outcome says to check Sent. `send_draft` refuses, pointing at the +draft in HEY: + +- any draft, from a client that can't show a confirmation (it answers with the draft's + link, and saying yes in the chat doesn't send it); +- a draft that is already scheduled, or has no recipients, or more than 20; +- a draft with a reference a mail client loads by itself, such as a remote image; +- a draft it can't show in full (an attachment, more than 1,500 characters, or + formatting that can hide text), when it goes to someone new; +- a draft to someone new within ten minutes of a read that may have returned an + unscreened sender's mail (search, threads, messages, drafts, contacts, the Screener, + calendar recordings; box reads don't count); +- past 50 sends, or 10 new addresses, in a day on one sign-in. + +That state (claims, the daily counts and the ten-minute marker) lives in +`~/.local/state/hey-cli/mcp-send-state.json`, under a file lock and fsynced, so two +`hey mcp` processes can't both send one draft. `send_draft` is served only when the +sign-in was granted sending, and never under `--read-only`. + +Earlier releases delivered mail when a call omitted `entry.status`, and then saved +drafts only. `describe` for the draft actions serves the draft-only schema, and the +`hey_threads` tool description says the same. A person can still send any draft from HEY +or with `hey draft send `. diff --git a/docs/cli.md b/docs/cli.md index 2b99fbb02..80562493e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,6 +61,16 @@ hey auth logout # clear credentials `hey login` and `hey logout` are top-level shortcuts for `hey auth login` and `hey auth logout`. +`hey mcp` has a sign-in of its own, kept apart from the CLI's and limited to reading, +writing and sending (see [agents.md](agents.md#its-own-sign-in)): + +```bash +hey auth login --mcp # sign hey mcp in +hey auth logout --mcp # revoke hey mcp's sign-in and remove it +``` + +`hey auth status` reports both. + ### Linked accounts One HEY login exposes every mail account linked to that identity. List the available diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a7d6ce7c8..681546862 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -28,7 +28,22 @@ const callbackListenAddr = "127.0.0.1:0" // Manager handles OAuth authentication. type Manager struct { - baseURL string + baseURL string + // storeKey is where the credential lives in the store: the origin for the CLI's + // own, "mcp::" and the origin for hey mcp's (mcp.go). + storeKey string + // clientID is the OAuth client this manager signs in and refreshes as. + clientID string + // scope is what a sign-in asks for. Empty asks for none, which HEY grants the + // CLI's own row as full. + scope string + // honorEnvToken lets HEY_TOKEN stand in for the stored credential. Only the CLI's + // manager honors it: the variable holds whatever token a person exported, usually + // a full one, and hey mcp must never end up holding that. + honorEnvToken bool + // wait sleeps between device-flow polls; a seam for tests. + wait func(context.Context, time.Duration) error + store *Store httpClient *http.Client callbackWait callbackWaiter @@ -63,11 +78,28 @@ const defaultRefreshHold = 15 * time.Minute // NewManager creates a new auth manager. func NewManager(baseURL string, httpClient *http.Client, configDir string) *Manager { listenConfig := &net.ListenConfig{} + origin := normalizeBaseURL(baseURL) return &Manager{ - baseURL: normalizeBaseURL(baseURL), - store: NewStore(configDir), - httpClient: httpClient, - listen: listenConfig.Listen, + baseURL: origin, + storeKey: origin, + clientID: oauthClientID, + honorEnvToken: true, + wait: sleepContext, + store: NewStore(configDir), + httpClient: httpClient, + listen: listenConfig.Listen, + } +} + +// sleepContext waits d, or until ctx is done. +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil } } @@ -85,9 +117,9 @@ func cloneCredentials(creds *Credentials) *Credentials { } // AccessToken returns a valid access token, refreshing if needed. -// If HEY_TOKEN env var is set, it's used directly. +// If HEY_TOKEN env var is set, the CLI's manager uses it directly. func (m *Manager) AccessToken(ctx context.Context) (string, error) { - if token := os.Getenv("HEY_TOKEN"); token != "" { + if token := m.envToken(); token != "" { return token, nil } @@ -135,8 +167,17 @@ func (m *Manager) AuthenticateRequestFromStore(ctx context.Context, req *http.Re return m.authenticateRequest(ctx, req, true) } +// envToken is HEY_TOKEN, for a manager that honors it. A nil manager is the CLI's, as +// it always was: HEY_TOKEN alone authenticates. +func (m *Manager) envToken() string { + if m != nil && !m.honorEnvToken { + return "" + } + return os.Getenv("HEY_TOKEN") +} + func (m *Manager) authenticateRequest(ctx context.Context, req *http.Request, reload bool) error { - if token := os.Getenv("HEY_TOKEN"); token != "" { + if token := m.envToken(); token != "" { req.Header.Set("Authorization", "Bearer "+token) return nil } @@ -182,7 +223,7 @@ func (m *Manager) authenticateRequest(ctx context.Context, req *http.Request, re // credential is a known signed-out state; a storage failure leaves the state // unknown and is returned to the caller. func (m *Manager) AuthenticationStatus() (bool, error) { - if os.Getenv("HEY_TOKEN") != "" { + if m.envToken() != "" { return true, nil } @@ -255,7 +296,10 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { return fmt.Errorf("invalid auth endpoint: %w", err) } q := u.Query() - q.Set("client_id", oauthClientID) + q.Set("client_id", m.clientID) + if m.scope != "" { + q.Set("scope", m.scope) + } q.Set("grant_type", "authorization_code") q.Set("redirect_uri", redirectURI) q.Set("state", state) @@ -276,11 +320,22 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { } // Exchange code for tokens - token, err := exchangeCode(ctx, m.httpClient, tokenEndpoint, code, redirectURI, oauthClientID, codeVerifier, installID) + token, err := exchangeCode(ctx, m.httpClient, tokenEndpoint, code, redirectURI, m.clientID, codeVerifier, installID) if err != nil { return fmt.Errorf("token exchange failed: %w", err) } + creds, err := m.signedInCredentials(token, tokenEndpoint, installID) + if err != nil { + return err + } + return m.saveCredentials(creds) +} + +// signedInCredentials is what a completed sign-in stores. A scoped sign-in (hey mcp's) +// also records the scope HEY granted, the client its refreshes must name, and when and +// under which local key the grant was made (mcp.go). +func (m *Manager) signedInCredentials(token *OAuthToken, tokenEndpoint, installID string) (*Credentials, error) { creds := &Credentials{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, @@ -291,8 +346,19 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { if !token.ExpiresAt.IsZero() { creds.ExpiresAt = token.ExpiresAt.Unix() } + if m.scope == "" { + return creds, nil + } - return m.saveCredentials(creds) + granted, err := grantedScope(m.scope, token.Scope) + if err != nil { + return nil, err + } + creds.Scope = granted + creds.ClientID = m.clientID + creds.GrantKey = generateState() + creds.GrantedAt = time.Now().Unix() + return creds, nil } // LoginWithToken stores a pre-provided bearer token. @@ -328,7 +394,7 @@ func (m *Manager) Logout() error { m.mu.Lock() defer m.mu.Unlock() - if err := m.store.Delete(m.baseURL); err != nil { + if err := m.store.Delete(m.storeKey); err != nil { return err } m.cachedCredentials = nil @@ -355,7 +421,7 @@ func (m *Manager) loadCredentialsLocked() (*Credentials, error) { } func (m *Manager) reloadCredentialsLocked() (*Credentials, error) { - creds, err := m.store.Load(m.baseURL) + creds, err := m.store.Load(m.storeKey) if err != nil { m.cachedCredentials = nil return nil, err @@ -368,7 +434,7 @@ func (m *Manager) saveCredentials(creds *Credentials) error { m.mu.Lock() defer m.mu.Unlock() - if err := m.store.Save(m.baseURL, creds); err != nil { + if err := m.store.Save(m.storeKey, creds); err != nil { return err } m.cachedCredentials = cloneCredentials(creds) @@ -387,7 +453,7 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { } defer unlock() - stored, loadErr := m.store.load(m.baseURL) + stored, loadErr := m.store.load(m.storeKey) if errors.Is(loadErr, ErrCredentialsNotFound) { // Another process deleted the credential while we waited for the lock. // Nothing stale may survive a forced read that proves it is gone. @@ -441,7 +507,14 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { return err } - token, err := refreshOAuthToken(ctx, m.httpClient, tokenEndpoint, creds.RefreshToken, oauthClientID, installID) + // A refresh names the client the tokens were issued to. hey mcp's are the local + // MCP client's, and HEY refuses a refresh token presented by another client. + clientID := creds.ClientID + if clientID == "" { + clientID = m.clientID + } + + token, err := refreshOAuthToken(ctx, m.httpClient, tokenEndpoint, creds.RefreshToken, clientID, installID) if err != nil { return m.accountForRefreshFailure(err, creds.RefreshToken) } @@ -451,6 +524,15 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { if token.AccessToken == "" { return fmt.Errorf("token refresh returned no access token") } + if creds.Scope != "" { + // A refresh keeps the grant's scope. Take HEY's word when it narrows it, and + // refuse an answer that names anything the grant never held. + refreshed, scopeErr := grantedScope(creds.Scope, token.Scope) + if scopeErr != nil { + return scopeErr + } + creds.Scope = refreshed + } creds.AccessToken = token.AccessToken if token.RefreshToken != "" { @@ -462,7 +544,7 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { creds.ExpiresAt = token.ExpiresAt.Unix() } - if err := m.store.save(m.baseURL, creds); err != nil { + if err := m.store.save(m.storeKey, creds); err != nil { return err } m.cachedCredentials = cloneCredentials(creds) @@ -520,7 +602,7 @@ func (m *Manager) accountForRefreshFailure(err error, sentRefreshToken string) e // Forget it under the lock that already spans this load-refresh-save, so the // next command asks for a login instead of re-sending it. - if delErr := m.store.delete(m.baseURL); delErr != nil { + if delErr := m.store.delete(m.storeKey); delErr != nil { // Keep the process-local guard even if the durable fallback also fails. m.refusedRefreshToken = sentRefreshToken @@ -529,7 +611,7 @@ func (m *Manager) accountForRefreshFailure(err error, sentRefreshToken string) e // process cannot load and submit it again. A later login overwrites this // through the ordinary save path. tombstone := &Credentials{} - if saveErr := m.store.save(m.baseURL, tombstone); saveErr != nil { + if saveErr := m.store.save(m.storeKey, tombstone); saveErr != nil { return errRefusedGrant(errors.Join(delErr, fmt.Errorf("replace refused credential: %w", saveErr))) } m.cachedCredentials = cloneCredentials(tombstone) @@ -610,9 +692,10 @@ func (m *Manager) GetStore() *Store { return m.store } -// CredentialKey returns the base URL used as the credential storage key. +// CredentialKey returns the credential's storage key: the base URL for the CLI's own +// credential, prefixed for hey mcp's (mcp.go). func (m *Manager) CredentialKey() string { - return m.baseURL + return m.storeKey } func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL string, listener net.Listener, opts LoginOptions) (string, error) { diff --git a/internal/auth/device.go b/internal/auth/device.go new file mode 100644 index 000000000..17982c9e4 --- /dev/null +++ b/internal/auth/device.go @@ -0,0 +1,221 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/basecamp/hey-cli/internal/version" +) + +// The device authorization grant (RFC 8628), as haystack#8707 and #9085 serve it. +// +// A device request names the client and the scope, and no resource: hey mcp's tokens +// are for HEY's own API. Until a HEY host runs #9085 with OAUTH_SCOPED_DEVICE_FLOW on, +// a scoped request is refused with invalid_request (or, on #9041 alone, the row with +// unauthorized_client), and the sign-in falls back to loopback authorization. + +// errDeviceFlowUnavailable means this HEY can't serve a scoped device sign-in, so the +// caller should sign in by loopback instead. +var errDeviceFlowUnavailable = errors.New("this HEY doesn't take scoped device sign-ins yet") + +// deviceGrantType is RFC 8628 §3.4's grant type. +const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// defaultDeviceInterval is RFC 8628 §3.2's default polling interval, and the step +// slow_down adds to it (§3.5). +const defaultDeviceInterval = 5 * time.Second + +// deviceAuthorization is a device authorization response (RFC 8628 §3.2). DeviceCode +// is a secret: it is sent back to the token endpoint and never shown. +type deviceAuthorization struct { + DeviceCode string `json:"device_code"` //nolint:gosec // G117: legitimate OAuth field + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// LoginDevice signs in with the device authorization grant, asking for the manager's +// scope. It returns errDeviceFlowUnavailable, wrapped with HEY's answer, when this HEY +// can't serve the request, so the caller can fall back to Login. +func (m *Manager) LoginDevice(ctx context.Context, opts LoginOptions) error { + tokenEndpoint := m.baseURL + "/oauth/tokens" + installID, err := m.store.InstallID() + if err != nil { + return fmt.Errorf("install id: %w", err) + } + + authorization, err := m.requestDeviceAuthorization(ctx) + if err != nil { + return err + } + + link := authorization.VerificationURIComplete + if link == "" { + link = authorization.VerificationURI + } + opts.log(fmt.Sprintf("\nTo sign in, open %s\nand check that HEY shows this code: %s\n", link, authorization.UserCode)) + if !opts.NoBrowser { + if browserErr := openBrowser(link); browserErr != nil { + opts.log("Couldn't open the browser automatically; open the link above.\n") + } + } + opts.log("\nWaiting for you to approve the sign-in in HEY...\n") + + interval := time.Duration(authorization.Interval) * time.Second + if interval <= 0 { + interval = defaultDeviceInterval + } + expiresAt := time.Now().Add(time.Duration(authorization.ExpiresIn) * time.Second) + + for { + if waitErr := m.wait(ctx, min(interval, max(time.Until(expiresAt), 0))); waitErr != nil { + return waitErr + } + if !time.Now().Before(expiresAt) { + return errors.New("the sign-in code expired before it was approved; run the sign-in again") + } + + token, code, pollErr := m.pollDeviceToken(ctx, tokenEndpoint, authorization.DeviceCode, installID) + if pollErr != nil { + return pollErr + } + switch code { + case "": + creds, credErr := m.signedInCredentials(token, tokenEndpoint, installID) + if credErr != nil { + return credErr + } + return m.saveCredentials(creds) + case "authorization_pending": + case "slow_down": + interval += defaultDeviceInterval + case "access_denied": + return errors.New("the sign-in was denied in HEY") + case "expired_token": + return errors.New("the sign-in code expired before it was approved; run the sign-in again") + case "invalid_scope": + return errors.New("HEY no longer allows this client the scope it asked for") + default: + return fmt.Errorf("device sign-in failed: %s", code) + } + } +} + +// requestDeviceAuthorization asks HEY for a device code (POST /oauth/device_authorizations). +func (m *Manager) requestDeviceAuthorization(ctx context.Context) (*deviceAuthorization, error) { + form := url.Values{"client_id": {m.clientID}} + if m.scope != "" { + form.Set("scope", m.scope) + } + status, body, err := m.postForm(ctx, m.baseURL+"/oauth/device_authorizations", form) + if err != nil { + return nil, fmt.Errorf("device authorization request failed: %w", err) + } + + if status != http.StatusOK { + code := oauthErrorCode(body) + if deviceFlowUnavailable(status, code) { + return nil, fmt.Errorf("%w (HTTP %d %s)", errDeviceFlowUnavailable, status, code) + } + return nil, fmt.Errorf("device authorization failed: HTTP %d %s", status, code) + } + + var authorization deviceAuthorization + if err := json.Unmarshal(body, &authorization); err != nil { + return nil, fmt.Errorf("parsing device authorization response: %w", err) + } + if authorization.DeviceCode == "" || authorization.UserCode == "" || authorization.VerificationURI == "" || authorization.ExpiresIn <= 0 { + return nil, errors.New("device authorization response is missing required fields") + } + return &authorization, nil +} + +// deviceFlowUnavailable reports whether a refused device request means this HEY can't +// serve it at all: no endpoint (an older HEY), the rollout switch off (invalid_request +// for a request naming scope, haystack#9085), or a row the device flow refuses +// (unauthorized_client, #9041 without #9085). +func deviceFlowUnavailable(status int, code string) bool { + switch status { + case http.StatusNotFound, http.StatusMethodNotAllowed: + return true + case http.StatusBadRequest, http.StatusUnauthorized: + return code == "invalid_request" || code == "unauthorized_client" || code == "unsupported_grant_type" + default: + return false + } +} + +// pollDeviceToken asks the token endpoint once whether the device code is approved. It +// returns the token, or the RFC 8628 §3.5 error code HEY answered. +func (m *Manager) pollDeviceToken(ctx context.Context, tokenEndpoint, deviceCode, installID string) (*OAuthToken, string, error) { + form := url.Values{ + "grant_type": {deviceGrantType}, + "client_id": {m.clientID}, + "device_code": {deviceCode}, + "install_id": {installID}, + } + status, body, err := m.postForm(ctx, tokenEndpoint, form) + if err != nil { + return nil, "", fmt.Errorf("device token request failed: %w", err) + } + + if status == http.StatusOK { + var token OAuthToken + if err := json.Unmarshal(body, &token); err != nil { + return nil, "", fmt.Errorf("parsing device token response: %w", err) + } + if token.AccessToken == "" { + return nil, "", errors.New("device token response has no access token") + } + if token.ExpiresIn > 0 { + token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + return &token, "", nil + } + + if code := oauthErrorCode(body); code != "" && status >= 400 && status < 500 { + return nil, code, nil + } + return nil, "", fmt.Errorf("device token request failed: HTTP %d", status) +} + +// postForm posts a form and answers the status and at most 64 KiB of the body. +func (m *Manager) postForm(ctx context.Context, endpoint string, form url.Values) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", version.UserAgent()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil +} + +// oauthErrorCode is the RFC 6749 §5.2 error code in an error response, or "". +func oauthErrorCode(body []byte) string { + var payload struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &payload) != nil { + return "" + } + return payload.Error +} diff --git a/internal/auth/mcp.go b/internal/auth/mcp.go new file mode 100644 index 000000000..6a988ca09 --- /dev/null +++ b/internal/auth/mcp.go @@ -0,0 +1,174 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/basecamp/hey-cli/internal/version" +) + +// hey mcp's credential (haystack#9041, #9085). +// +// `hey mcp` signs in as HEY's local MCP client, "HEY MCP (local)", not as the CLI. The +// row's allowlist is read write send and never full, so a model driving hey mcp can +// reach what those scopes declare and nothing else: no settings, forwarding, trash or +// HEY World. It has its own entry in Connected Apps and can be revoked without signing +// the CLI out. +// +// The credential is stored apart from the CLI's: its own keychain item (and its own +// entry in the plaintext fallback file), under storeKey "mcp::". It refreshes +// as the local client. HEY_TOKEN never stands in for it. + +// MCPScope is what `hey auth login --mcp` asks for. A scoped row that names no scope +// is refused (haystack#9085), so the sign-in always sends it. +const MCPScope = "read write send" + +// mcpStoreKeyPrefix keeps hey mcp's credential out of the CLI's keychain item. +const mcpStoreKeyPrefix = "mcp::" + +// ErrMCPClientUnconfigured means this HEY has no local MCP client id configured yet. +var ErrMCPClientUnconfigured = errors.New("no HEY MCP (local) client id is configured for this HEY") + +// NewMCPManager is the manager for hey mcp's credential, signing in and refreshing as +// the local MCP client clientID. +func NewMCPManager(baseURL string, httpClient *http.Client, configDir, clientID string) *Manager { + m := NewManager(baseURL, httpClient, configDir) + m.storeKey = mcpStoreKeyPrefix + m.baseURL + m.clientID = clientID + m.scope = MCPScope + m.honorEnvToken = false + return m +} + +// ClientID is the OAuth client this manager signs in as. +func (m *Manager) ClientID() string { + return m.clientID +} + +// LoginMCP signs in the local MCP client. It uses the device flow where HEY serves +// scoped device requests, and otherwise loopback authorization with the same scope, +// returning which one it used ("device" or "oauth"). +func (m *Manager) LoginMCP(ctx context.Context, opts LoginOptions) (string, error) { + switch m.clientID { + case "": + return "", ErrMCPClientUnconfigured + case oauthClientID: + // A mistyped HEY_MCP_CLIENT_ID pointing at the CLI's own row would sign hey mcp + // in with full, which is the thing this credential exists to avoid. + return "", fmt.Errorf("the configured MCP client id is the CLI's own; set the HEY MCP (local) client id") + } + + err := m.LoginDevice(ctx, opts) + if !errors.Is(err, errDeviceFlowUnavailable) { + return "device", err + } + opts.log(fmt.Sprintf("\n%v, so signing in through the browser instead.\n", err)) + return "oauth", m.Login(ctx, opts) +} + +// StoredCredentials reads this manager's stored credential. +func (m *Manager) StoredCredentials() (*Credentials, error) { + return m.store.Load(m.storeKey) +} + +// Revoke asks HEY to revoke the stored grant (DELETE /oauth/tokens). It sends the +// refresh token when there is one, since revoking it ends the whole grant. +func (m *Manager) Revoke(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + creds, err := m.store.Load(m.storeKey) + if err != nil { + return err + } + token := creds.RefreshToken + if token == "" { + token = creds.AccessToken + } + if token == "" { + return nil + } + clientID := creds.ClientID + if clientID == "" { + clientID = m.clientID + } + + form := url.Values{"client_id": {clientID}, "token": {token}} + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, m.baseURL+"/oauth/tokens", strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("creating revocation request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", version.UserAgent()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("revocation request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // The body can echo the request, so only the status is reported. + return fmt.Errorf("HEY answered the revocation with HTTP %d", resp.StatusCode) + } + return nil +} + +// scopeImplies is HEY's scope taxonomy as far as hey mcp reads it: write implies +// read (haystack Oauth::Scope), and full implies everything. +var scopeImplies = map[string][]string{ + "write": {"read"}, +} + +// expandScope returns the scopes granted holds, implications included. +func expandScope(granted string) map[string]bool { + held := map[string]bool{} + for _, s := range strings.Fields(granted) { + held[s] = true + for _, implied := range scopeImplies[s] { + held[implied] = true + } + } + return held +} + +// HasScope reports whether a granted scope string holds every scope in want, by the +// expanded set rather than the literal string. +func HasScope(granted string, want ...string) bool { + held := expandScope(granted) + if held["full"] { + return true + } + for _, w := range want { + if !held[w] { + return false + } + } + return true +} + +// grantedScope reads the scope a token response granted against what was asked for. +// An omitted scope means exactly what was requested (RFC 6749 §5.1). A narrower one is +// taken as it is, since the person may have unchecked send on the consent page. One that +// names anything not asked for, full above all, is refused: it would mean the client id +// isn't the local MCP client's. +func grantedScope(requested, returned string) (string, error) { + if strings.TrimSpace(returned) == "" { + return strings.Join(strings.Fields(requested), " "), nil + } + asked := strings.Fields(requested) + var granted []string + for _, s := range strings.Fields(returned) { + if !slices.Contains(asked, s) { + return "", fmt.Errorf("HEY granted scope %q, which this sign-in didn't ask for; check that the MCP client id is HEY MCP (local)'s", s) + } + if !slices.Contains(granted, s) { + granted = append(granted, s) + } + } + return strings.Join(granted, " "), nil +} diff --git a/internal/auth/mcp_test.go b/internal/auth/mcp_test.go new file mode 100644 index 000000000..610f1c1ba --- /dev/null +++ b/internal/auth/mcp_test.go @@ -0,0 +1,441 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const testMCPClientID = "LocalMcpClient0000000001" + +// fakeHEYOAuth is HEY's OAuth endpoints as the local MCP client meets them. +type fakeHEYOAuth struct { + t *testing.T + + mu sync.Mutex + // deviceStatus and deviceError answer the device authorization request; zero + // serves a device code. + deviceStatus int + deviceError string + // pending is how many polls answer authorization_pending before the token. + pending int + // pollErrors answer polls in order before pending counts down. + pollErrors []string + // grantedScope is the token response's scope; "-" leaves it out. + grantedScope string + + deviceRequests url.Values + polls []url.Values + exchanges []url.Values + refreshes []url.Values + revocations []url.Values +} + +func (f *fakeHEYOAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + // net/http parses a form body only for POST, PUT and PATCH; Rack parses one on any + // method, DELETE /oauth/tokens included, so read it directly. + raw, _ := io.ReadAll(r.Body) + form, err := url.ParseQuery(string(raw)) + if err != nil { + f.t.Errorf("parse form: %v", err) + } + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/oauth/device_authorizations": + f.deviceRequests = form + if f.deviceStatus != 0 { + w.WriteHeader(f.deviceStatus) + fmt.Fprintf(w, `{"error":%q}`, f.deviceError) + return + } + fmt.Fprint(w, `{"device_code":"secret-device-code","user_code":"WDJB-MJHT","verification_uri":"https://hey.example/device","verification_uri_complete":"https://hey.example/device?user_code=WDJB-MJHT","expires_in":600,"interval":5}`) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == deviceGrantType: + f.polls = append(f.polls, form) + if len(f.pollErrors) > 0 { + code := f.pollErrors[0] + f.pollErrors = f.pollErrors[1:] + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":%q}`, code) + return + } + if f.pending > 0 { + f.pending-- + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + f.writeToken(w, "device") + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == "authorization_code": + f.exchanges = append(f.exchanges, form) + f.writeToken(w, "loopback") + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == "refresh_token": + f.refreshes = append(f.refreshes, form) + f.writeToken(w, "refreshed") + case r.Method == http.MethodDelete && r.URL.Path == "/oauth/tokens": + f.revocations = append(f.revocations, form) + fmt.Fprint(w, `{}`) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +func (f *fakeHEYOAuth) writeToken(w http.ResponseWriter, kind string) { + body := map[string]any{"access_token": kind + "-access", "refresh_token": kind + "-refresh", "token_type": "bearer", "expires_in": 1209600} + switch f.grantedScope { + case "-": + case "": + body["scope"] = MCPScope + default: + body["scope"] = f.grantedScope + } + _ = json.NewEncoder(w).Encode(body) +} + +// mcpRig is a CLI manager and an MCP manager sharing one keychain, against one HEY. +type mcpRig struct { + hey *fakeHEYOAuth + server *httptest.Server + keyring *fakeKeyring + cli *Manager + mcp *Manager + waits []time.Duration +} + +func newMCPRig(t *testing.T) *mcpRig { + t.Helper() + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "") + rig := &mcpRig{hey: &fakeHEYOAuth{t: t}, keyring: newFakeKeyring()} + rig.server = httptest.NewServer(rig.hey) + t.Cleanup(rig.server.Close) + + configDir := t.TempDir() + rig.cli = NewManager(rig.server.URL, rig.server.Client(), configDir) + rig.mcp = NewMCPManager(rig.server.URL, rig.server.Client(), configDir, testMCPClientID) + for _, m := range []*Manager{rig.cli, rig.mcp} { + m.store.keyring = credentialKeyring{set: rig.keyring.Set, get: rig.keyring.Get, delete: rig.keyring.Delete} + } + rig.mcp.wait = func(_ context.Context, d time.Duration) error { + rig.waits = append(rig.waits, d) + return nil + } + // No test opens a browser: the device link and the loopback URL are only logged. + rig.mcp.callbackWait = func(_ context.Context, state, authURL string, listener net.Listener, _ LoginOptions) (string, error) { + u, err := url.Parse(authURL) + if err != nil { + return "", err + } + rig.hey.mu.Lock() + rig.hey.exchanges = append(rig.hey.exchanges, url.Values{"authorize": {u.RawQuery}}) + rig.hey.mu.Unlock() + if u.Query().Get("state") != state { + return "", errors.New("state mismatch") + } + return "loopback-code", nil + } + return rig +} + +var quiet = LoginOptions{NoBrowser: true, Logger: func(string) {}} + +func TestMCPLoginStoresACredentialApartFromTheCLIs(t *testing.T) { + rig := newMCPRig(t) + if err := rig.cli.LoginWithToken("cli-full-token"); err != nil { + t.Fatal(err) + } + rig.hey.pending = 2 + + method, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err != nil { + t.Fatal(err) + } + if method != "device" { + t.Errorf("method = %q, want device", method) + } + + // The device request names the local client and the scope, and no resource. + got := rig.hey.deviceRequests + if got.Get("client_id") != testMCPClientID || got.Get("scope") != MCPScope || got.Has("resource") { + t.Errorf("device request = %v", got) + } + for _, poll := range rig.hey.polls { + if poll.Get("client_id") != testMCPClientID || poll.Get("device_code") != "secret-device-code" || !isInstallID(poll.Get("install_id")) { + t.Errorf("poll = %v", poll) + } + } + if len(rig.hey.polls) != 3 { + t.Errorf("polled %d times, want 3 (two pending, then the token)", len(rig.hey.polls)) + } + + // Two keychain items: the CLI's, untouched, and hey mcp's. + cli, err := rig.cli.StoredCredentials() + if err != nil || cli.AccessToken != "cli-full-token" { + t.Fatalf("the CLI's credential = %+v, %v", cli, err) + } + mcp, err := rig.mcp.StoredCredentials() + if err != nil { + t.Fatal(err) + } + if mcp.AccessToken != "device-access" || mcp.Scope != MCPScope || mcp.ClientID != testMCPClientID || mcp.GrantKey == "" || mcp.GrantedAt == 0 { + t.Errorf("hey mcp's credential = %+v", mcp) + } + cliItem, mcpItem := serviceName+"/"+key(rig.cli.CredentialKey()), serviceName+"/"+key(rig.mcp.CredentialKey()) + if cliItem == mcpItem { + t.Fatalf("both credentials live in keychain item %s", cliItem) + } + if _, ok := rig.keyring.values[cliItem]; !ok { + t.Errorf("no CLI keychain item %s", cliItem) + } + if _, ok := rig.keyring.values[mcpItem]; !ok { + t.Errorf("no hey mcp keychain item %s", mcpItem) + } +} + +func TestMCPLoginStoresApartInThePlaintextFallbackToo(t *testing.T) { + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + hey := &fakeHEYOAuth{t: t} + server := httptest.NewServer(hey) + t.Cleanup(server.Close) + dir := t.TempDir() + + cli := NewManager(server.URL, server.Client(), dir) + if err := cli.LoginWithToken("cli-full-token"); err != nil { + t.Fatal(err) + } + mcp := NewMCPManager(server.URL, server.Client(), dir, testMCPClientID) + mcp.wait = func(context.Context, time.Duration) error { return nil } + if _, err := mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + + all, err := cli.store.loadAllFromFile() + if err != nil { + t.Fatal(err) + } + if len(all) != 2 || all[cli.CredentialKey()].AccessToken != "cli-full-token" || all[mcp.CredentialKey()].AccessToken != "device-access" { + t.Errorf("credentials.json entries = %v", keysOf(all)) + } +} + +func keysOf(all map[string]*Credentials) []string { + keys := make([]string, 0, len(all)) + for k := range all { + keys = append(keys, k) + } + return keys +} + +func TestMCPLoginFallsBackToLoopbackWithTheSameScope(t *testing.T) { + for name, refusal := range map[string]struct { + status int + code string + }{ + "rollout switch off (haystack#9085)": {http.StatusBadRequest, "invalid_request"}, + "row refused by the device flow (#9041)": {http.StatusBadRequest, "unauthorized_client"}, + "no device endpoint": {http.StatusNotFound, ""}, + } { + t.Run(name, func(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceStatus, rig.hey.deviceError = refusal.status, refusal.code + + method, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err != nil { + t.Fatal(err) + } + if method != "oauth" { + t.Errorf("method = %q, want the loopback fallback", method) + } + authorize, _ := url.ParseQuery(rig.hey.exchanges[0].Get("authorize")) + if authorize.Get("client_id") != testMCPClientID || authorize.Get("scope") != MCPScope || authorize.Get("code_challenge_method") != "S256" { + t.Errorf("authorization request = %v", authorize) + } + exchange := rig.hey.exchanges[1] + if exchange.Get("client_id") != testMCPClientID { + t.Errorf("code exchange named client %q", exchange.Get("client_id")) + } + creds, err := rig.mcp.StoredCredentials() + if err != nil || creds.AccessToken != "loopback-access" || creds.Scope != MCPScope { + t.Errorf("stored %+v, %v", creds, err) + } + }) + } +} + +func TestMCPLoginDoesntFallBackOnOtherRefusals(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceStatus, rig.hey.deviceError = http.StatusUnauthorized, "invalid_client" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil { + t.Fatal("an unknown client id signed in") + } + if len(rig.hey.exchanges) != 0 { + t.Error("fell back to loopback for a refusal that isn't about the device flow") + } +} + +func TestMCPLoginHonorsTheScopeHEYGranted(t *testing.T) { + for name, tc := range map[string]struct { + granted, stored string + canSend bool + }{ + "all of it": {"read write send", "read write send", true}, + "send unchecked": {"read write", "read write", false}, + "left out means what was asked": {"-", "read write send", true}, + } { + t.Run(name, func(t *testing.T) { + rig := newMCPRig(t) + rig.hey.grantedScope = tc.granted + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + creds, _ := rig.mcp.StoredCredentials() + if creds.Scope != tc.stored { + t.Errorf("stored scope %q, want %q", creds.Scope, tc.stored) + } + if got := HasScope(creds.Scope, "read", "send"); got != tc.canSend { + t.Errorf("can send = %v, want %v", got, tc.canSend) + } + }) + } +} + +func TestMCPLoginRefusesAScopeItDidntAskFor(t *testing.T) { + rig := newMCPRig(t) + rig.hey.grantedScope = "full" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil || !strings.Contains(err.Error(), "full") { + t.Fatalf("err = %v, want a refusal naming full", err) + } + if _, err := rig.mcp.StoredCredentials(); !errors.Is(err, ErrCredentialsNotFound) { + t.Errorf("a full credential was stored for hey mcp: %v", err) + } +} + +func TestMCPLoginNeedsTheLocalClientID(t *testing.T) { + rig := newMCPRig(t) + rig.mcp.clientID = "" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); !errors.Is(err, ErrMCPClientUnconfigured) { + t.Errorf("err = %v, want ErrMCPClientUnconfigured", err) + } + rig.mcp.clientID = oauthClientID + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil { + t.Error("hey mcp signed in as the CLI's own client") + } + if rig.hey.deviceRequests != nil { + t.Error("HEY was asked anyway") + } +} + +func TestMCPDeviceLoginStopsOnDenialAndBacksOffOnSlowDown(t *testing.T) { + rig := newMCPRig(t) + rig.hey.pollErrors = []string{"slow_down", "access_denied"} + _, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("err = %v, want denial", err) + } + if len(rig.waits) != 2 || rig.waits[0] != 5*time.Second || rig.waits[1] != 10*time.Second { + t.Errorf("waits = %v, want 5s then 10s after slow_down", rig.waits) + } + if _, err := rig.mcp.StoredCredentials(); !errors.Is(err, ErrCredentialsNotFound) { + t.Error("a denied sign-in stored a credential") + } +} + +func TestMCPRefreshNamesTheLocalClientAndKeepsTheScope(t *testing.T) { + rig := newMCPRig(t) + if err := rig.mcp.store.Save(rig.mcp.storeKey, &Credentials{ + AccessToken: "old", RefreshToken: "old-refresh", ExpiresAt: time.Now().Add(-time.Hour).Unix(), + OAuthType: "oauth", InstallID: generateInstallIDForTest(t), Scope: MCPScope, ClientID: testMCPClientID, GrantKey: "g", GrantedAt: 1, + }); err != nil { + t.Fatal(err) + } + rig.hey.grantedScope = "read write" + + token, err := rig.mcp.AccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "refreshed-access" { + t.Errorf("token = %q", token) + } + if got := rig.hey.refreshes[0].Get("client_id"); got != testMCPClientID { + t.Errorf("refresh named client %q, want the local MCP client", got) + } + creds, _ := rig.mcp.StoredCredentials() + if creds.Scope != "read write" || creds.GrantKey != "g" || creds.ClientID != testMCPClientID { + t.Errorf("after refresh: %+v", creds) + } +} + +func generateInstallIDForTest(t *testing.T) string { + t.Helper() + id, err := NewStore(t.TempDir()).InstallID() + if err != nil { + t.Fatal(err) + } + return id +} + +func TestMCPManagerNeverUsesHEYToken(t *testing.T) { + rig := newMCPRig(t) + t.Setenv("HEY_TOKEN", "a-full-token-someone-exported") + + if ok, _ := rig.mcp.AuthenticationStatus(); ok { + t.Error("HEY_TOKEN signed hey mcp in") + } + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/boxes.json", nil) + if err := rig.mcp.AuthenticateRequest(context.Background(), req); err == nil || req.Header.Get("Authorization") != "" { + t.Errorf("HEY_TOKEN authenticated a hey mcp request: %q", req.Header.Get("Authorization")) + } + if ok, _ := rig.cli.AuthenticationStatus(); !ok { + t.Error("the CLI stopped honoring HEY_TOKEN") + } +} + +func TestMCPRevokeNamesTheLocalClientAndTheGrant(t *testing.T) { + rig := newMCPRig(t) + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + if err := rig.mcp.Revoke(context.Background()); err != nil { + t.Fatal(err) + } + if len(rig.hey.revocations) != 1 { + t.Fatalf("revocations = %d", len(rig.hey.revocations)) + } + got := rig.hey.revocations[0] + if got.Get("client_id") != testMCPClientID || got.Get("token") != "device-refresh" { + t.Errorf("revocation = client %q, token is the refresh token: %v", got.Get("client_id"), got.Get("token") == "device-refresh") + } +} + +func TestHasScopeReadsTheExpandedSet(t *testing.T) { + for _, tc := range []struct { + granted string + want []string + ok bool + }{ + {"read write send", []string{"read", "send"}, true}, + {"write send", []string{"read", "send"}, true}, + {"read write", []string{"send"}, false}, + {"send", []string{"read", "send"}, false}, + {"", []string{"read"}, false}, + } { + if got := HasScope(tc.granted, tc.want...); got != tc.ok { + t.Errorf("HasScope(%q, %v) = %v, want %v", tc.granted, tc.want, got, tc.ok) + } + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index e307273cb..c6f83bc3d 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -98,11 +98,14 @@ func clampRetryAfter(d time.Duration) time.Duration { // OAuthToken represents the token response from the HEY OAuth server. type OAuthToken struct { - AccessToken string `json:"access_token"` //nolint:gosec // G117: legitimate OAuth field - RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: legitimate OAuth field - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - ExpiresAt time.Time `json:"-"` + AccessToken string `json:"access_token"` //nolint:gosec // G117: legitimate OAuth field + RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: legitimate OAuth field + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + // Scope is what HEY granted, space-separated. RFC 6749 §5.1 lets a server leave it + // out when it granted exactly what was asked for. + Scope string `json:"scope"` + ExpiresAt time.Time `json:"-"` } // exchangeCode exchanges an authorization code for tokens using PKCE. diff --git a/internal/auth/store.go b/internal/auth/store.go index 6d6fed8d0..7c4975839 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -42,6 +42,21 @@ type Credentials struct { // travels with the tokens: every config directory that shares this keychain entry // refreshes as the same install. InstallID string `json:"install_id,omitempty"` + + // The fields below are set only by a scoped sign-in, hey mcp's (mcp.go). The CLI's own + // credential asks for no scope, holds full, and leaves them empty. + + // Scope is what HEY granted these tokens, space-separated, e.g. "read write send". + Scope string `json:"scope,omitempty"` + // ClientID is the OAuth client the tokens were issued to, which a refresh names. + ClientID string `json:"client_id,omitempty"` + // GrantKey names this sign-in locally. hey mcp keys its send safety state (caps and + // exposure) on it, so a new sign-in, which took the person through consent again, + // starts a new budget. + GrantKey string `json:"grant_key,omitempty"` + // GrantedAt is when the sign-in completed, in Unix seconds. hey mcp counts a + // recipient as known only from mail sent before it. + GrantedAt int64 `json:"granted_at,omitempty"` } // Store handles credential storage, preferring system keychain. diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index c381e32cf..c3fa36dde 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -52,6 +52,7 @@ func buildLoginCommand(path string) *cobra.Command { token string cookie string noBrowser bool + mcp bool ) cmd := &cobra.Command{ @@ -60,14 +61,28 @@ func buildLoginCommand(path string) *cobra.Command { Long: `Authenticate with the HEY server. Opens a browser for OAuth authentication against HEY's own OAuth server, using PKCE. -Use --token or --cookie for non-interactive login.`, +Use --token or --cookie for non-interactive login. + +--mcp signs in hey mcp instead, as its own app ("HEY MCP (local)" in HEY's Connected +Apps), asking to read, write and send but nothing more. Its credential is kept apart +from the CLI's, and hey mcp serves on it alone. It uses a device code where HEY +offers one, and the browser otherwise. If you uncheck sending on HEY's page, hey mcp +saves drafts but can't send them.`, Example: strings.Join([]string{ " " + path, " " + path + " --token YOUR_BEARER_TOKEN", " " + path + " --cookie SESSION_COOKIE_VALUE", " " + path + " --no-browser", + " " + path + " --mcp", }, "\n"), RunE: func(cmd *cobra.Command, args []string) error { + if mcp { + if token != "" || cookie != "" { + return apierr.ErrUsage("--mcp signs in through HEY; it takes no --token or --cookie") + } + return loginMCP(cmd, noBrowser) + } + if token != "" { if err := authMgr.LoginWithToken(token); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not save token: %v", err)) @@ -113,6 +128,7 @@ Use --token or --cookie for non-interactive login.`, cmd.Flags().StringVar(&token, "token", "", "Pre-generated Bearer token") cmd.Flags().StringVar(&cookie, "cookie", "", "Session cookie value from browser (__Host-session_token, or session_token)") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser, print URL instead") + cmd.Flags().BoolVar(&mcp, "mcp", false, "Sign in hey mcp with its own credential (read, write and send)") return cmd } @@ -126,11 +142,19 @@ func newAuthLogoutCommand() *cobra.Command { // buildLogoutCommand constructs a logout command whose example reads as the // given path. Shared by `hey auth logout` and the top-level `hey logout`. func buildLogoutCommand(path string) *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Clear stored credentials", - Example: " " + path, + var mcp bool + cmd := &cobra.Command{ + Use: "logout", + Short: "Clear stored credentials", + Long: `Clear the CLI's stored credentials. + +--mcp signs hey mcp out instead: it revokes hey mcp's sign-in at HEY and removes its +credential, leaving the CLI's alone.`, + Example: " " + path + "\n " + path + " --mcp", RunE: func(cmd *cobra.Command, args []string) error { + if mcp { + return logoutMCP(cmd) + } if err := authMgr.Logout(); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not clear credentials: %v", err)) } @@ -139,6 +163,8 @@ func buildLogoutCommand(path string) *cobra.Command { return writeMutation(cmd, "Logged out", nil) }, } + cmd.Flags().BoolVar(&mcp, "mcp", false, "Sign hey mcp out: revoke its sign-in and remove its credential") + return cmd } // status subcommand @@ -148,14 +174,16 @@ func newAuthStatusCommand() *cobra.Command { Use: "status", Short: "Show authentication status", Annotations: map[string]string{ - "agent_notes": "Use it when the user asks for authentication status or when an explicit check helps diagnose a failed command, not as a preflight. It reports stored credentials without contacting HEY and changes nothing, so authenticated true does not prove HEY still accepts them. JSON answers authenticated, base_url, mail_account, account_source and install_id; method env_var when HEY_TOKEN is set; otherwise storage (keyring or file), auth_type, and expires_at, expired and refresh_available for a token. An expired token with refresh_available is refreshed by the next data command.", + "agent_notes": "Use it when the user asks for authentication status or when an explicit check helps diagnose a failed command, not as a preflight. It reports stored credentials without contacting HEY and changes nothing, so authenticated true does not prove HEY still accepts them. JSON answers authenticated, base_url, mail_account, account_source and install_id; method env_var when HEY_TOKEN is set; otherwise storage (keyring or file), auth_type, and expires_at, expired and refresh_available for a token. An expired token with refresh_available is refreshed by the next data command. mcp reports hey mcp's own sign-in the same way, with its scope and can_send; HEY_TOKEN never stands in for it.", }, RunE: func(cmd *cobra.Command, args []string) error { + mcpStatus := mcpCredentialStatus() status := map[string]any{ "base_url": cfg.BaseURL, "mail_account": cfg.AccountID, "account_source": cfg.SourceOf("account_id"), "authenticated": false, + "mcp": mcpStatus, } // The install identifier is install-scoped: it survives logout and @@ -180,6 +208,7 @@ func newAuthStatusCommand() *cobra.Command { fmt.Fprintf(w, "Install: %s\n", installID) } fmt.Fprintln(w, "Status: Logged in (via HEY_TOKEN env var)") + printMCPStatus(w, mcpStatus) return nil } return writeOK(status, output.WithSummary("Logged in via HEY_TOKEN")) @@ -199,6 +228,7 @@ func newAuthStatusCommand() *cobra.Command { fmt.Fprintf(w, "Install: %s\n", installID) } fmt.Fprintln(w, "Status: Not logged in") + printMCPStatus(w, mcpStatus) return nil } return writeOK(status, output.WithSummary("Not logged in"), @@ -268,6 +298,7 @@ func newAuthStatusCommand() *cobra.Command { } else { fmt.Fprintln(w, "Storage: file") } + printMCPStatus(w, mcpStatus) return nil } diff --git a/internal/cmd/help_topics.go b/internal/cmd/help_topics.go index a57f2d0d7..0265f81f3 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -55,7 +55,8 @@ JSON failures also carry a machine-readable error code and an actionable hint wh Long: `Configure hey for the current process with environment variables. CONNECTION & AUTHENTICATION - HEY_TOKEN Use a bearer token instead of stored credentials. + HEY_TOKEN Use a bearer token instead of stored credentials (never for hey mcp). + HEY_MCP_CLIENT_ID The HEY MCP (local) client id hey auth login --mcp signs in as. HEY_BASE_URL Override the HEY server URL. HEY_ACCOUNT_ID Select a linked mail account ID or all. HEY_NO_KEYRING Store credentials in the config directory instead of a keyring. diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index d6de84d11..286d45dda 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -5,12 +5,17 @@ import ( "log/slog" "os" "os/signal" + "strings" "syscall" + "time" + "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" "github.com/basecamp/hey-cli/internal/mcpserver" ) @@ -30,12 +35,19 @@ func newMCPCommand() *mcpCommand { Use: "mcp", Short: "Serve HEY to MCP clients over stdio", Long: "Run an MCP (Model Context Protocol) server on stdin/stdout, serving HEY mail,\n" + - "contacts, and todos as tools backed by your signed-in account.\n\n" + - "It never sends mail. create_message, create_reply and update_message only save\n" + - "drafts, and a request to send, schedule or triage in the same call is refused\n" + - "before HEY is contacted. Updating a scheduled draft clears its schedule. Send a\n" + - "draft yourself from HEY or with `hey draft send `. --read-only drops every\n" + - "write action, drafts included.\n\n" + + "contacts, and todos as tools.\n\n" + + "hey mcp has its own sign-in, separate from the CLI's: run `hey auth login --mcp`\n" + + "first. It signs in as \"HEY MCP (local)\", which can read, write and send but\n" + + "can't reach settings, forwarding, trash or HEY World, and shows in HEY's Connected\n" + + "Apps on its own. hey mcp never uses the CLI's credential or HEY_TOKEN.\n\n" + + "create_message, create_reply and update_message only save drafts, and a request\n" + + "to send, schedule or triage in the same call is refused before HEY is contacted.\n" + + "Updating a scheduled draft clears its schedule. hey_send's send_draft is the one\n" + + "way to deliver: it asks you to confirm each email in your MCP client, showing its\n" + + "recipients, subject and body, and sends exactly what you approved, once. A client\n" + + "that can't show a confirmation gets the draft's link in HEY instead. send_draft\n" + + "is served only when the sign-in was granted sending. --read-only drops every\n" + + "write action, drafts and sending included.\n\n" + "Register it with an MCP client as a stdio server, e.g.:\n\n" + " claude mcp add hey -- hey mcp", Args: cobra.NoArgs, @@ -86,18 +98,48 @@ func (a mcpAPI) Delete(ctx context.Context, path string) (*hey.Response, error) } func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { - if err := requireAuth(); err != nil { + // hey mcp serves on its own credential, never the CLI's (mcp_auth.go). + creds, err := requireMCPCredentials() + if err != nil { return err } - // The same account scoping the shared client got in the root command's - // pre-run, applied to the no-retry twin. - writes, err := clientForAccountSelection(cmd.Context(), newSDKClient(hey.WithMaxRetries(0)), cfg.AccountID) + ctx := cmd.Context() + reads, err := clientForAccountSelection(ctx, newMCPSDKClient(), cfg.AccountID) if err != nil { return err } + writes, err := clientForAccountSelection(ctx, newMCPSDKClient(hey.WithMaxRetries(0)), cfg.AccountID) + if err != nil { + return err + } + + // The tools follow the grant: without write, nothing that writes is served; without + // send (and read, which send_draft's review needs), no hey_send. + readOnly := c.readOnly || !auth.HasScope(creds.Scope, "write") + serverCfg := mcpserver.Config{ReadOnly: readOnly, Domains: c.domains} + // The sign-in's local key is the principal send safety counts against, so a + // credential without one serves no send. + canSend := auth.HasScope(creds.Scope, "read", "send") && creds.GrantKey != "" + if canSend && !readOnly { + state, stateErr := mcpserver.OpenSendState(config.StateDir()) + if stateErr != nil { + return stateErr + } + // An undated sign-in makes no recipient known: only mail sent before it does. + var grantedAt time.Time + if creds.GrantedAt > 0 { + grantedAt = time.Unix(creds.GrantedAt, 0) + } + serverCfg.Send = &mcpserver.SendConfig{ + Drafts: mcpDrafts{reads: reads, writes: writes}, + State: state, + Grant: mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt}, + Origin: strings.TrimRight(cfg.BaseURL, "/"), + } + } - srv, err := mcpserver.New(mcpAPI{reads: sdk, writes: writes}, mcpserver.Config{ReadOnly: c.readOnly, Domains: c.domains}) + srv, err := mcpserver.New(mcpAPI{reads: reads, writes: writes}, serverCfg) if err != nil { return err } @@ -111,7 +153,43 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - logger.Info("MCP server running on stdio", "tools", len(srv.Domains()), "read_only", c.readOnly) + logger.Info("MCP server running on stdio", "tools", len(srv.Domains()), "read_only", readOnly, "send_draft", srv.ServesSend()) + if !canSend { + logger.Info("send_draft isn't served: hey mcp's sign-in wasn't granted sending. To allow it, run `hey auth login --mcp` again and leave sending checked.") + } return session.Wait() } + +// newMCPSDKClient builds a client like the CLI's, authenticated with hey mcp's own +// credential instead. It keeps no response cache: the cache is the CLI credential's, and +// what one credential fetched isn't the other's to read. +func newMCPSDKClient(extra ...hey.ClientOption) *hey.Client { + uncached := *sdkClientCfg + uncached.CacheEnabled = false + uncached.CacheDir = "" + opts := append(append([]hey.ClientOption{}, sdkClientOpts...), hey.WithAuthStrategy(&cliAuthStrategy{mgr: mcpAuthMgr})) + return hey.NewClient(&uncached, nil, append(opts, extra...)...) +} + +// mcpDrafts is send_draft's view of the SDK: reads through the retrying client, the send +// through the one that never retries, because a retried send can deliver twice. +type mcpDrafts struct { + reads, writes *hey.Client +} + +func (d mcpDrafts) GetEdit(ctx context.Context, draftID int64) (*generated.MessageEditState, error) { + return d.reads.Messages().GetEdit(ctx, draftID) +} + +func (d mcpDrafts) SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) { + return d.writes.Messages().SendDraft(ctx, draftID, draft) +} + +func (d mcpDrafts) Identity(ctx context.Context) (*generated.Identity, error) { + return d.reads.Identity().GetIdentity(ctx) +} + +func (d mcpDrafts) Search(ctx context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) { + return d.reads.Search().Search(ctx, params) +} diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go new file mode 100644 index 000000000..79786fb38 --- /dev/null +++ b/internal/cmd/mcp_auth.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" +) + +// hey mcp's own credential (internal/auth/mcp.go): `hey auth login --mcp` signs in the +// HEY MCP (local) client on read write send, stored apart from the CLI's credential, and +// `hey mcp` serves on that alone. + +var ( + // mcpAuthMgr manages hey mcp's credential. Built in the root pre-run beside authMgr. + mcpAuthMgr *auth.Manager + // errMCPClientIDConfig is why the configured local MCP client id was refused, if it was. + errMCPClientIDConfig error +) + +func initMCPAuth(httpClient *http.Client, configDir string) { + clientID, _, err := config.MCPClientID(cfg.BaseURL) + errMCPClientIDConfig = err + mcpAuthMgr = auth.NewMCPManager(cfg.BaseURL, httpClient, configDir, clientID) +} + +const mcpLoginCommand = "hey auth login --mcp" + +// errMCPNotSignedIn is hey mcp run without its credential. +func errMCPNotSignedIn() *apierr.Error { + err := apierr.ErrAuth("hey mcp has its own sign-in, separate from the CLI's, and it isn't signed in") + err.Hint = "Run: " + mcpLoginCommand + return err +} + +func errMCPClientUnconfigured() *apierr.Error { + err := apierr.ErrUsage("hey mcp's sign-in isn't available for this HEY yet: no HEY MCP (local) client id is configured") + err.Hint = "Set HEY_MCP_CLIENT_ID, or mcp_client_ids in ~/.config/hey-cli/config.json, to the client id HEY provisioned" + return err +} + +// requireMCPCredentials loads hey mcp's credential, refusing when there is none. It never +// falls back to the CLI's credential: that one holds full, and serving hey mcp on it would +// undo what the scoped sign-in contains. +func requireMCPCredentials() (*auth.Credentials, error) { + creds, err := mcpAuthMgr.StoredCredentials() + if errors.Is(err, auth.ErrCredentialsNotFound) { + return nil, errMCPNotSignedIn() + } + if err != nil { + return nil, fmt.Errorf("could not read hey mcp's credentials: %w", err) + } + if creds.AccessToken == "" && creds.RefreshToken == "" { + return nil, errMCPNotSignedIn() + } + return creds, nil +} + +// loginMCP runs `hey auth login --mcp`. +func loginMCP(cmd *cobra.Command, noBrowser bool) error { + if errMCPClientIDConfig != nil { + return apierr.ErrUsage(errMCPClientIDConfig.Error()) + } + + // A device code lives ten minutes; the loopback fallback waits five. + ctx, cancel := context.WithTimeout(cmd.Context(), 12*time.Minute) + defer cancel() + + method, err := mcpAuthMgr.LoginMCP(ctx, auth.LoginOptions{NoBrowser: noBrowser}) + if errors.Is(err, auth.ErrMCPClientUnconfigured) { + return errMCPClientUnconfigured() + } + if err != nil { + return apierr.ErrAuth(fmt.Sprintf("hey mcp sign-in failed: %v", err)) + } + + creds, err := requireMCPCredentials() + if err != nil { + return err + } + canSend := auth.HasScope(creds.Scope, "read", "send") + line := fmt.Sprintf("Signed hey mcp in (%s).", creds.Scope) + if !canSend { + line += " Sending wasn't granted, so hey mcp saves drafts but can't send them; sign in again and leave sending checked to allow it." + } + return writeMutationLine(cmd, line, "hey mcp signed in", + map[string]any{"method": method, "scope": creds.Scope, "can_send": canSend}) +} + +// logoutMCP runs `hey auth logout --mcp`: revoke the grant at HEY, then forget it here. +// The local copy goes even when the revocation can't be confirmed, and the warning says +// where to finish it. +func logoutMCP(cmd *cobra.Command) error { + if _, err := mcpAuthMgr.StoredCredentials(); errors.Is(err, auth.ErrCredentialsNotFound) { + return writeMutation(cmd, "hey mcp wasn't signed in", nil) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) + defer cancel() + revokeErr := mcpAuthMgr.Revoke(ctx) + if revokeErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: couldn't revoke hey mcp's sign-in at HEY (%v); remove HEY MCP (local) in HEY's Connected Apps\n", revokeErr) + } + if err := mcpAuthMgr.Logout(); err != nil { + return apierr.ErrAuth(fmt.Sprintf("could not clear hey mcp's credentials: %v", err)) + } + return writeMutationLine(cmd, "hey mcp signed out.", "hey mcp signed out", + map[string]any{"revoked": revokeErr == nil}) +} + +// mcpCredentialStatus is hey mcp's half of `hey auth status`. It never includes a token. +func mcpCredentialStatus() map[string]any { + status := map[string]any{"authenticated": false} + if mcpAuthMgr == nil { + return status + } + if clientID := mcpAuthMgr.ClientID(); clientID != "" { + status["client_id"] = clientID + } else { + status["client_configured"] = false + } + + creds, err := mcpAuthMgr.StoredCredentials() + if err != nil { + if !errors.Is(err, auth.ErrCredentialsNotFound) { + status["error"] = err.Error() + } + return status + } + if creds.AccessToken == "" && creds.RefreshToken == "" { + return status + } + status["authenticated"] = true + status["scope"] = creds.Scope + status["can_send"] = auth.HasScope(creds.Scope, "read", "send") + if creds.ExpiresAt > 0 { + expiry := time.Unix(creds.ExpiresAt, 0) + status["expires_at"] = expiry.Format(time.RFC3339) + status["expired"] = time.Now().After(expiry) + } + if creds.RefreshToken != "" { + status["refresh_available"] = true + } + if creds.GrantedAt > 0 { + status["signed_in_at"] = time.Unix(creds.GrantedAt, 0).Format(time.RFC3339) + } + return status +} + +// printMCPStatus is the styled line for hey mcp's credential. +func printMCPStatus(w io.Writer, status map[string]any) { + if status["authenticated"] != true { + fmt.Fprintf(w, "MCP: Not signed in (run: %s)\n", mcpLoginCommand) + return + } + line := fmt.Sprintf("MCP: Signed in (%v)", status["scope"]) + if status["can_send"] != true { + line += ", can't send" + } + if status["expired"] == true { + line += ", token expired" + } + fmt.Fprintln(w, line) +} diff --git a/internal/cmd/mcp_auth_test.go b/internal/cmd/mcp_auth_test.go new file mode 100644 index 000000000..61f9747a6 --- /dev/null +++ b/internal/cmd/mcp_auth_test.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +// fakeLocalMCPOAuth serves the device flow and revocation for the local MCP client. +type fakeLocalMCPOAuth struct { + t *testing.T + scope string + mu sync.Mutex + revocations []url.Values +} + +func (f *fakeLocalMCPOAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(raw)) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/oauth/device_authorizations": + if form.Get("client_id") != "LocalMcpClient0000000001" || form.Get("scope") != "read write send" { + f.t.Errorf("device request = %v", form) + } + fmt.Fprint(w, `{"device_code":"secret-device-code","user_code":"WDJB-MJHT","verification_uri":"https://hey.example/device","expires_in":600,"interval":1}`) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + fmt.Fprintf(w, `{"access_token":"mcp-access","refresh_token":"mcp-refresh","token_type":"bearer","expires_in":1209600,"scope":%q}`, f.scope) + case r.Method == http.MethodDelete && r.URL.Path == "/oauth/tokens": + f.mu.Lock() + f.revocations = append(f.revocations, form) + f.mu.Unlock() + fmt.Fprint(w, `{}`) + default: + f.t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +func TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBoth(t *testing.T) { + hey := &fakeLocalMCPOAuth{t: t, scope: "read write"} + server := httptest.NewServer(hey) + defer server.Close() + configHome := t.TempDir() + t.Setenv("HEY_MCP_CLIENT_ID", "LocalMcpClient0000000001") + + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--token", "cli-full-token"); err != nil { + t.Fatal(err) + } + _, login, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--mcp", "--no-browser") + if err != nil { + t.Fatal(err) + } + data, _ := login.Data.(map[string]any) + if data["method"] != "device" || data["scope"] != "read write" || data["can_send"] != false { + t.Errorf("login data = %v", data) + } + + _, status, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatal(err) + } + got, _ := status.Data.(map[string]any) + mcpStatus, _ := got["mcp"].(map[string]any) + if got["authenticated"] != true || got["auth_type"] != "token" { + t.Errorf("the CLI's credential changed: %v", got) + } + if mcpStatus["authenticated"] != true || mcpStatus["scope"] != "read write" || mcpStatus["can_send"] != false { + t.Errorf("mcp status = %v", mcpStatus) + } + if text := fmt.Sprint(got); strings.Contains(text, "mcp-access") || strings.Contains(text, "mcp-refresh") || strings.Contains(text, "cli-full-token") { + t.Error("status printed a token") + } + + styled, _, err := runAuthCommand(t, configHome, server.URL, "", false, "auth", "status", "--styled") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "MCP: Signed in (read write), can't send") { + t.Errorf("styled status = %s", styled) + } + + _, logout, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "logout", "--mcp") + if err != nil { + t.Fatal(err) + } + if data, _ := logout.Data.(map[string]any); data["revoked"] != true { + t.Errorf("logout data = %v", logout.Data) + } + if len(hey.revocations) != 1 || hey.revocations[0].Get("client_id") != "LocalMcpClient0000000001" || hey.revocations[0].Get("token") != "mcp-refresh" { + t.Errorf("revocations = %d, want one of the MCP refresh token by the local client", len(hey.revocations)) + } + + _, status, err = runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatal(err) + } + got, _ = status.Data.(map[string]any) + mcpStatus, _ = got["mcp"].(map[string]any) + if got["authenticated"] != true || mcpStatus["authenticated"] != false { + t.Errorf("after logout --mcp: cli %v, mcp %v; want the CLI still signed in and hey mcp out", got["authenticated"], mcpStatus["authenticated"]) + } +} + +func TestAuthLoginMCPNeedsAClientID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + t.Setenv("HEY_MCP_CLIENT_ID", "") + + _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "login", "--mcp") + var usage *apierr.Error + if !errors.As(err, &usage) || !strings.Contains(usage.Hint, "HEY_MCP_CLIENT_ID") { + t.Fatalf("err = %#v, want a usage error naming HEY_MCP_CLIENT_ID", err) + } +} + +func TestAuthLoginMCPTakesNoTokenOrCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + if _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "login", "--mcp", "--token", "x"); err == nil { + t.Fatal("--mcp stored a hand-supplied token") + } +} diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 03095118b..04355cf35 100644 --- a/internal/cmd/mcp_test.go +++ b/internal/cmd/mcp_test.go @@ -4,13 +4,20 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "slices" "strings" "sync/atomic" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" ) func TestMCPCommandRegistration(t *testing.T) { @@ -29,28 +36,52 @@ func TestMCPCommandRegistration(t *testing.T) { t.Error("domains flag missing") } - if !commandUsesAccountScope(command) { - t.Error("mcp must use the configured account scope") + // hey mcp scopes its own clients to the configured account, on its own credential; + // the pre-run's account scoping would ask HEY with the CLI's. + if commandUsesAccountScope(command) { + t.Error("the pre-run must not scope hey mcp's account with the CLI's credential") } } -func TestMCPCommandRequiresAuth(t *testing.T) { +// hey mcp refuses to start without its own credential, even with the CLI signed in +// and HEY_TOKEN set: both hold full, and serving on either would undo the scoped +// sign-in. Nothing reaches HEY. +func TestMCPCommandRefusesWithoutItsOwnCredential(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(upstream.Close) + tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) t.Setenv("XDG_STATE_HOME", tmpDir) t.Setenv("XDG_CACHE_HOME", tmpDir) - t.Setenv("HEY_TOKEN", "") t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_TOKEN", "a-full-token") stubInteractive(t, false) + if err := auth.NewManager(upstream.URL, upstream.Client(), config.ConfigDir()).LoginWithToken("the-clis-full-token"); err != nil { + t.Fatal(err) + } + // If hey mcp started serving instead of refusing, it would wait on this transport. + _ = stubMCPTransport(t) root := newRootCmd() var buf bytes.Buffer root.SetOut(&buf) root.SetErr(&buf) - root.SetArgs([]string{"mcp"}) + root.SetArgs([]string{"mcp", "--base-url", upstream.URL}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "Not logged in") { - t.Fatalf("err = %v, want auth error", err) + done := make(chan error, 1) + go func() { done <- root.Execute() }() + var err error + select { + case err = <-done: + case <-time.After(5 * time.Second): + t.Fatal("hey mcp started serving without its own credential") + } + var refusal *apierr.Error + if !errors.As(err, &refusal) || refusal.Code != apierr.CodeAuth || !strings.Contains(refusal.Hint, "hey auth login --mcp") { + t.Fatalf("err = %#v, want an auth error pointing at hey auth login --mcp", err) } } @@ -65,17 +96,44 @@ func stubMCPTransport(t *testing.T) mcp.Transport { return clientTransport } -// runMCPCommand runs `hey mcp` against a stub HEY server and connects a real -// MCP client to it over the transport seam. The command exits when the -// client session closes. +// mcpTestToken is the access token of the hey mcp credential the tests store. +const mcpTestToken = "mcp-scoped-token" + +// storeMCPCredential stores a hey mcp credential granted scope, as `hey auth login +// --mcp` would, signed in a week ago. +func storeMCPCredential(t *testing.T, baseURL, scope string) { + t.Helper() + mgr := auth.NewMCPManager(baseURL, http.DefaultClient, config.ConfigDir(), "LocalMcpClient0000000001") + creds := &auth.Credentials{ + AccessToken: mcpTestToken, RefreshToken: "mcp-refresh", OAuthType: "oauth", + ExpiresAt: time.Now().Add(time.Hour).Unix(), Scope: scope, ClientID: "LocalMcpClient0000000001", + GrantKey: "test-grant", GrantedAt: time.Now().Add(-7 * 24 * time.Hour).Unix(), + } + if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { + t.Fatal(err) + } +} + +// runMCPCommand runs `hey mcp` against a stub HEY server, signed in on its own +// credential with read write send, and connects a real MCP client to it over the +// transport seam. The command exits when the client session closes. func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp.ClientSession { t.Helper() - t.Setenv("HEY_TOKEN", "test-token") + return runMCPCommandWith(t, upstream, auth.MCPScope, nil, args...) +} + +// runMCPCommandWith is runMCPCommand with the credential's scope and the client's +// options given. +func runMCPCommandWith(t *testing.T, upstream *httptest.Server, scope string, clientOpts *mcp.ClientOptions, args ...string) *mcp.ClientSession { + t.Helper() + // HEY_TOKEN holds whatever a person exported; hey mcp must never send it. + t.Setenv("HEY_TOKEN", "a-full-token-hey-mcp-must-not-use") t.Setenv("HEY_NO_KEYRING", "1") tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) t.Setenv("XDG_STATE_HOME", tmpDir) t.Setenv("XDG_CACHE_HOME", tmpDir) + storeMCPCredential(t, upstream.URL, scope) clientTransport := stubMCPTransport(t) @@ -93,7 +151,7 @@ func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp } }) - client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, clientOpts) session, err := client.Connect(context.Background(), clientTransport, nil) if err != nil { t.Fatalf("MCP initialize failed: %v", err) @@ -109,9 +167,9 @@ func TestMCPCommandServesMCP(t *testing.T) { http.NotFound(w, r) return } - // Tool calls must ride on the CLI's own credentials. - if got := r.Header.Get("Authorization"); got != "Bearer test-token" { - t.Errorf("Authorization = %q, want the CLI's token", got) + // Tool calls ride on hey mcp's own credential, never the CLI's or HEY_TOKEN. + if got := r.Header.Get("Authorization"); got != "Bearer "+mcpTestToken { + t.Errorf("Authorization = %q, want hey mcp's own token", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"id":1,"name":"Imbox"}]`)) @@ -131,8 +189,8 @@ func TestMCPCommandServesMCP(t *testing.T) { } names = append(names, tool.Name) } - if len(names) != 7 { - t.Fatalf("tools = %v, want 7 hey_* tools", names) + if len(names) != 8 || !slices.Contains(names, "hey_send") { + t.Fatalf("tools = %v, want the 7 domain tools and hey_send", names) } result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ @@ -284,3 +342,119 @@ func TestMCPCommandSavesDraftsAndNeverSends(t *testing.T) { t.Errorf("result = %s", text) } } + +func toolNames(t *testing.T, session *mcp.ClientSession) []string { + t.Helper() + var names []string + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + names = append(names, tool.Name) + } + return names +} + +// The tools follow the scope HEY granted: a sign-in with send unchecked serves no +// hey_send, and one without write serves nothing that writes. +func TestMCPCommandServesWhatTheGrantAllows(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(upstream.Close) + + t.Run("send unchecked", func(t *testing.T) { + names := toolNames(t, runMCPCommandWith(t, upstream, "read write", nil)) + if slices.Contains(names, "hey_send") || len(names) != 7 { + t.Errorf("tools = %v, want the 7 domain tools and no hey_send", names) + } + }) + t.Run("read only", func(t *testing.T) { + session := runMCPCommandWith(t, upstream, "read", nil) + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if tool.Name == "hey_send" || strings.Contains(tool.Description, "- trash_topic") { + t.Errorf("a read-only grant serves a write: %s", tool.Name) + } + } + }) + t.Run("--read-only", func(t *testing.T) { + if names := toolNames(t, runMCPCommand(t, upstream, "--read-only")); slices.Contains(names, "hey_send") { + t.Errorf("--read-only serves hey_send: %v", names) + } + }) +} + +// End to end through the command: send_draft reads the draft, asks the person, and +// sends the approved snapshot once, on hey mcp's own credential, through the client +// that never retries. +func TestMCPCommandSendsAConfirmedDraftOnce(t *testing.T) { + var puts atomic.Int32 + var putBody atomic.Value + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+mcpTestToken { + t.Errorf("%s %s Authorization = %q, want hey mcp's own token", r.Method, r.URL.Path, got) + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json": + _, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
Hi Ann
","sender":{"id":77,"name":"Jeremy Daer","email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"name":"Ann Example","email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"primary_contact":{"id":77,"email_address":"jeremy@hey.example"},"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/messages/5.json": + puts.Add(1) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + putBody.Store(body) + _, _ = w.Write([]byte(`{"id":5,"topic_id":900}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + var prompt string + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + call := func() *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + return result + } + + sent := call() + if sent.IsError { + t.Fatalf("send_draft failed: %v", sent.Content) + } + if !strings.Contains(prompt, `"Ann Example" (new: never emailed before)`) { + t.Errorf("prompt = %s", prompt) + } + body, _ := putBody.Load().(map[string]any) + entry, _ := body["entry"].(map[string]any) + if entry["status"] != nil || entry["addressed"] == nil { + t.Errorf("delivery entry = %v, want recipients and no drafted status", entry) + } + + again := call() + if !again.IsError { + t.Fatalf("a second send_draft of the same draft went through: %v", again.Content) + } + if got := puts.Load(); got != 1 { + t.Errorf("HEY saw %d deliveries, want 1", got) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 86abf1318..7a295b345 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -121,6 +121,7 @@ func newRootCmd() *cobra.Command { // that credential any more than it outlives a logout. authMgr.OnCredentialCleared(func() { clearHTTPCache(cmd.ErrOrStderr()) }) initSDK(authMgr, cfg.BaseURL) + initMCPAuth(httpClient, configDir) // The agent-local setup subcommands and skill commands never read // credentials and never rewrite the global config, so they defer @@ -276,6 +277,10 @@ func commandUsesAccountScope(cmd *cobra.Command) bool { switch parts[1] { case "account", "auth", "commands", "config", "doctor", "login", "logout", "setup", "shell-completion", "skill", "upgrade", "version": return false + case "mcp": + // hey mcp scopes its own clients, on its own credential (mcp.go): the shared + // client the pre-run would scope carries the CLI's. + return false default: return true } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 8d3637c15..543b0b980 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -129,13 +129,6 @@ func clearHTTPCache(errOut io.Writer) { } } -// newSDKClient builds another client sharing the CLI's configuration — auth, -// user agent, hooks, logging — plus any extra options. Valid after initSDK. -func newSDKClient(extra ...hey.ClientOption) *hey.Client { - opts := append(append([]hey.ClientOption{}, sdkClientOpts...), extra...) - return hey.NewClient(sdkClientCfg, nil, opts...) -} - // newUncachedSDKClient builds a sibling of sdk — same configuration, same account — // without the response cache, for a read a 304 must not answer. The cache revalidates // on HEY's ETag, and an ETag can cover less than the body: /boxes.json's is the box diff --git a/internal/config/mcp.go b/internal/config/mcp.go new file mode 100644 index 000000000..ee23c736e --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,83 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "regexp" +) + +// DefaultMCPClientID is the client id of "HEY MCP (local)", the OAuth row `hey mcp` +// signs in as (haystack#9041), for every HEY environment. +// +// TODO(provision): empty until the production row exists. Jeremy provisions production +// first with `bin/rails oauth:provision_local_mcp_client`, which prints a generated +// id; beta runs on production's database and shares that row, and staging and dev are +// then provisioned with CLIENT_ID=, so this one constant serves them all, as +// the CLI's own id does. Until it is set, `hey auth login --mcp` needs HEY_MCP_CLIENT_ID +// or a configured id (MCPClientID). +const DefaultMCPClientID = "" + +// mcpClientIDPattern is what haystack's provisioning accepts for a client id: letters +// and digits. +var mcpClientIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`) + +// MCPClientID resolves the local MCP client id for the HEY at baseURL, and where it came +// from: HEY_MCP_CLIENT_ID, then the global config's "mcp_client_ids" entry for that +// origin, then DefaultMCPClientID. It is read from the global config only, never a +// repository's .hey/config.json, so a checkout can't point hey mcp's sign-in at another +// client. An empty id means none is configured. +func MCPClientID(baseURL string) (string, Source, error) { + if env := os.Getenv("HEY_MCP_CLIENT_ID"); env != "" { + if !mcpClientIDPattern.MatchString(env) { + return "", SourceEnv, fmt.Errorf("HEY_MCP_CLIENT_ID must be letters and digits") + } + return env, SourceEnv, nil + } + + origin, err := serverOrigin(baseURL) + if err != nil { + return "", SourceDefault, err + } + ids, err := globalMCPClientIDs() + if err != nil { + return "", SourceDefault, err + } + if id, ok := ids[origin]; ok && id != "" { + if !mcpClientIDPattern.MatchString(id) { + return "", SourceGlobal, fmt.Errorf("mcp_client_ids for %s must be letters and digits", origin) + } + return id, SourceGlobal, nil + } + return DefaultMCPClientID, SourceDefault, nil +} + +// globalMCPClientIDs reads the global config's "mcp_client_ids", keyed by origin. +func globalMCPClientIDs() (map[string]string, error) { + path := globalConfigPath() + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) // #nosec G304 -- the fixed user configuration file + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read config %s: %w", path, err) + } + var file struct { + MCPClientIDs map[string]string `json:"mcp_client_ids"` + } + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("could not parse config %s: %w", path, err) + } + ids := make(map[string]string, len(file.MCPClientIDs)) + for origin, id := range file.MCPClientIDs { + normalized, originErr := serverOrigin(origin) + if originErr != nil { + return nil, fmt.Errorf("could not parse mcp_client_ids origin %s in %s: %w", origin, path, originErr) + } + ids[normalized] = id + } + return ids, nil +} diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go new file mode 100644 index 000000000..d7f8b857a --- /dev/null +++ b/internal/config/mcp_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMCPClientIDResolution(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HEY_MCP_CLIENT_ID", "") + + id, source, err := MCPClientID("https://app.hey.com") + if err != nil || id != DefaultMCPClientID || source != SourceDefault { + t.Errorf("no config: %q %q %v, want the default", id, source, err) + } + + dir := filepath.Join(home, configDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + global := `{"base_url":"https://app.hey.com","mcp_client_ids":{"https://app.hey.com/":"ProdLocalMcp000000000001","http://app.hey.localhost:3003":"DevLocalMcp0000000000001"}}` + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(global), 0o600); err != nil { + t.Fatal(err) + } + if id, source, _ := MCPClientID("https://app.hey.com"); id != "ProdLocalMcp000000000001" || source != SourceGlobal { + t.Errorf("production: %q %q", id, source) + } + if id, _, _ := MCPClientID("http://app.hey.localhost:3003"); id != "DevLocalMcp0000000000001" { + t.Errorf("dev: %q, want the id configured for its origin", id) + } + + t.Setenv("HEY_MCP_CLIENT_ID", "EnvLocalMcp0000000000001") + if id, source, _ := MCPClientID("https://app.hey.com"); id != "EnvLocalMcp0000000000001" || source != SourceEnv { + t.Errorf("env: %q %q, want the environment to win", id, source) + } + + t.Setenv("HEY_MCP_CLIENT_ID", "not an id&scope=full") + if _, _, err := MCPClientID("https://app.hey.com"); err == nil { + t.Error("a malformed HEY_MCP_CLIENT_ID was accepted") + } +} diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go index cb7882f9b..9035112aa 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -42,6 +42,9 @@ var _ API = (*hey.Client)(nil) // three. Failures are in-band isError results per MCP convention. type dispatcher struct { api API + // markExposed records that a read may have returned an unscreened sender's words + // (exposure.go). Nil when nothing is served that exposure could tighten. + markExposed func(ctx context.Context, action string) error } func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.Operation, params map[string]any) (*mcp.CallToolResult, error) { @@ -78,6 +81,13 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O } return gateway.ErrorResult("%v", err), nil } + // The exposure marker lands before the content leaves (D6). A read not in the + // table exposes: it can't be shown not to. + if exposes, classified := readExposes[full.ID]; d.markExposed != nil && full.ReadOnly && (exposes || !classified) { + if err := d.markExposed(ctx, full.Action); err != nil { + return gateway.ErrorResult("hey mcp can't return %s right now: it couldn't record that the answer may include mail from an unscreened sender (%v)", full.Action, err), nil + } + } if draft { if !savedAsDraft(resp) { return gateway.ErrorResult("%s", unconfirmedDraft(full.Action, resp)), nil diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index 6dd1e1972..2c4dbb806 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -26,7 +26,7 @@ var DomainSpecs = []catalog.DomainSpec{ { Key: "threads", Tags: []string{"Topics", "Entries", "Messages"}, - Blurb: "HEY email threads: topics and their entries, renaming a thread, full message content, replies and forwards, drafts, and triage (trash, spam, restore, move). create_message, create_reply and update_message only ever save drafts: they never send or schedule mail, and a request to send, schedule or triage in the same call is refused. Updating a scheduled draft clears its schedule. There is no send action; a person sends a draft from HEY or with `hey draft send `.", + Blurb: "HEY email threads: topics and their entries, renaming a thread, full message content, replies and forwards, drafts, and triage (trash, spam, restore, move). create_message, create_reply and update_message only ever save drafts: they never send or schedule mail, and a request to send, schedule or triage in the same call is refused. Updating a scheduled draft clears its schedule. Delivery is hey_send's send_draft, which asks the person to confirm each email, where it's served; a person can also send a draft from HEY or with `hey draft send `.", }, { Key: "contacts", diff --git a/internal/mcpserver/drafts.go b/internal/mcpserver/drafts.go index fe4d811c0..558a0b127 100644 --- a/internal/mcpserver/drafts.go +++ b/internal/mcpserver/drafts.go @@ -29,8 +29,10 @@ import ( // schedule when an update omits it, so editing a scheduled draft unschedules // it rather than letting it go out later. // -// MCP has no way to deliver mail yet: there is no send_draft action. A person -// sends the draft from HEY or with `hey draft send `. +// These actions never deliver. Delivery is send_draft (send.go), in its own hey_send +// tool, which asks the person to confirm each email, and is served only when hey mcp's +// sign-in can send. A person can also send the draft from HEY or with +// `hey draft send `. // TestEveryServedOpenWorldOperationIsDraftFirst holds this list total: mapping // another operation that can reach people outside the mailbox fails until it // has a rule here. @@ -45,8 +47,8 @@ var draftEntryKeys = map[string]bool{"addressed": true, "status": true} const drafted = "drafted" -// howToSend is how a person delivers a draft this server saved. -const howToSend = "a person sends it from HEY or with `hey draft send `" +// howToSend is how a draft this server saved gets delivered. +const howToSend = "call hey_send's send_draft with its draft_id, which asks the person to confirm the email (where this server serves hey_send), or a person sends it from HEY or with `hey draft send `" // draftOnly returns params with the entry forced to a draft, or an error // naming what would have delivered. params is not modified. @@ -59,12 +61,12 @@ func draftOnly(action string, params map[string]any) (map[string]any, error) { } for _, key := range slices.Sorted(maps.Keys(given)) { if !draftEntryKeys[key] { - return nil, fmt.Errorf("%s saves a draft and doesn't take entry.%s: hey mcp never sends or schedules mail, and triage has its own actions (allowed: entry.%s). Nothing was sent to HEY. To deliver, save the draft, then %s", + return nil, fmt.Errorf("%s saves a draft and doesn't take entry.%s: it never sends or schedules mail, and triage has its own actions (allowed: entry.%s). Nothing was sent to HEY. To deliver, save the draft, then %s", action, key, strings.Join(slices.Sorted(maps.Keys(draftEntryKeys)), ", entry."), howToSend) } } if status, ok := given["status"]; ok && status != drafted { - return nil, fmt.Errorf("%s saves a draft: entry.status can only be %q, because hey mcp never sends mail. Nothing was sent to HEY. To deliver, save the draft, then %s", + return nil, fmt.Errorf("%s saves a draft: entry.status can only be %q, because it never sends mail. Nothing was sent to HEY. To deliver, save the draft, then %s", action, drafted, howToSend) } maps.Copy(entry, given) @@ -137,9 +139,9 @@ func draftResult(resp *hey.Response) map[string]any { result := map[string]any{"status": resp.StatusCode, "location": location, "drafted": true} if id := draftID(location); id != "" { result["draft_id"] = id - result["note"] = "Saved as a draft, not sent. hey mcp doesn't deliver mail: a person reviews and sends it from HEY, or with `hey draft send " + id + "`." + result["note"] = "Saved as a draft, not sent. To deliver it, call hey_send's send_draft with draft_id " + id + ", which asks the person to confirm it (where this server serves hey_send), or a person sends it from HEY or with `hey draft send " + id + "`." } else { - result["note"] = "Saved as a draft, not sent. hey mcp doesn't deliver mail: " + howToSend + "." + result["note"] = "Saved as a draft, not sent. To deliver it, " + howToSend + "." } return result } diff --git a/internal/mcpserver/exposure.go b/internal/mcpserver/exposure.go new file mode 100644 index 000000000..97dddbdb1 --- /dev/null +++ b/internal/mcpserver/exposure.go @@ -0,0 +1,72 @@ +package mcpserver + +// Exposure on the stdio gateway (sending-safety D6). +// +// A grant is exposed for ten minutes after a read returns anything an unscreened sender +// wrote, and while it's exposed send_draft refuses drafts to anyone not known (D4). The +// gateway serves HEY's responses raw, and most of them don't say whether a sender is +// screened in (GetTopic's screened_in, haystack#9083, isn't in the hey-sdk model this +// package embeds), so each served read is classified by what it can return: +// +// - false: it can't carry an unscreened sender's words. A box read lists postings, +// and HEY posts a thread to a box only once the Screener let it through +// (hey-mcp-server's provenance.go reads boxes the same way). Calendars, identity +// and box groups are the account's own. +// - true: it can, or it can't be ruled out. Search, threads, messages, drafts (a reply +// draft quotes what it answers), the Screener, contacts (a contact may be a sender +// waiting in the Screener) and calendar recordings (an invite's title is its +// sender's) all expose. +// +// The marker is written before the content is returned, and a read whose marker can't be +// written is refused, so unscreened content never reaches the model unrecorded. +// TestEveryServedReadIsClassifiedForExposure holds this table total: serving another +// read fails until it has an entry here. +var readExposes = map[string]bool{ + // boxes + "GetAsidebox": false, + "GetBox": false, + "GetBoxGroup": false, + "GetBoxPostingChanges": false, + "GetBubblebox": false, + "GetFeedbox": false, + "GetImbox": false, + "GetImboxSeen": false, + "GetLaterbox": false, + "GetTrailbox": false, + "ListBoxGroups": false, + "ListBoxes": false, + + // search + "AdvancedSearch": true, + "GetAdvancedSearchFilters": true, + + // threads + "GetEverythingTopics": true, + "GetMessage": true, + "GetMessageEdit": true, + "GetSentTopics": true, + "GetSpamTopics": true, + "GetTopic": true, + "GetTopicCommentAudience": true, + "GetTopicEntries": true, + "GetTrashTopics": true, + "ListDrafts": true, + "NewEntryForward": true, + "NewEntryReply": true, + + // contacts and the Screener + "GetClearances": true, + "GetContact": true, + "GetContactNote": true, + "GetMyClearances": true, + "ListAddressableContacts": true, + "ListContacts": true, + + // calendar + "GetCalendarRecordings": true, + "ListCalendars": false, + + // identity + "GetIdentity": false, + "GetNavigation": false, +} diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go new file mode 100644 index 000000000..9aa07ff24 --- /dev/null +++ b/internal/mcpserver/send.go @@ -0,0 +1,668 @@ +package mcpserver + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/mcp/gateway" +) + +// send_draft: delivery, confirmed by the person and single-use (sending-safety D2-D6). +// +// It is served in a tool of its own, hey_send, so its annotations can say what it is +// (destructive, open world) without marking every mail read so (D7). It is served only +// when hey mcp's credential holds send and read, and never under --read-only. +// +// A call runs in two rounds. The first fetches the draft, applies the recipient and +// exposure policy (D4, D6), and asks the person to confirm through MCP elicitation, with +// the draft's fingerprint signed into the request state. The second, carrying the +// person's answer and that state, fetches the draft again, refuses it if anything the +// fingerprint covers changed, applies the policy again, claims the draft and charges the +// caps in one locked update of the state file, and sends once, writing every delivered +// field from the snapshot it just verified. A client on the 2026-07-28 protocol carries +// the rounds itself (SEP-2322); for an older client the SDK elicits and calls back in +// the same request. A client that declares no elicitation gets a draft and a link (D3). +// +// What isn't here yet: send_at (scheduled delivery, which D2 also routes through +// send_draft). A draft that's already scheduled is refused. + +// Drafts is the slice of hey-sdk send_draft drives. GetEdit, Identity and Search read; +// SendDraft must go through a client that never retries, because a retried send can +// deliver twice (D2.4). +type Drafts interface { + GetEdit(ctx context.Context, draftID int64) (*generated.MessageEditState, error) + SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) + Identity(ctx context.Context) (*generated.Identity, error) + Search(ctx context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) +} + +// Grant is the security principal on stdio: hey mcp's sign-in (D4). +type Grant struct { + // Key names the sign-in; caps and exposure are counted per key. + Key string + // GrantedAt is when it was made. Only mail sent before it makes a recipient known. + GrantedAt time.Time +} + +// SendConfig serves send_draft. Nil serves none. +type SendConfig struct { + Drafts Drafts + State *SendState + Grant Grant + // Origin is the HEY this runs against, e.g. https://app.hey.com: links point there, + // and claims are keyed by it. + Origin string + // Now is the clock; nil is time.Now. + Now func() time.Time +} + +const ( + sendToolName = "hey_send" + sendDraftName = "send_draft" + confirmInputID = "confirm_send" + + // confirmKind and confirmMethod mark a signed state as a send_draft confirmation, + // so no other validator accepts it (basecamp-mcp-server's confirm.go). + confirmKind = "send_draft" + confirmMethod = "tools/call\x00send_draft" + + // confirmTTL: a send confirmation should not outlive the person's attention (D2). + confirmTTL = 5 * time.Minute + + // D4's caps. Starting values to tune from beta usage. + maxRecipients = 20 + maxSendsPerDay = 50 + maxNewAddressesPerDay = 10 + + // bodyPromptCap is how much of the body the confirmation shows (D2). + bodyPromptCap = 1500 + // headerCap bounds each single-line header field in the confirmation. + headerCap = 200 +) + +type sender struct { + cfg SendConfig + key []byte +} + +func newSender(cfg SendConfig) (*sender, error) { + switch { + case cfg.Drafts == nil || cfg.State == nil: + return nil, errors.New("send_draft needs HEY's drafts and a state file") + case cfg.Grant.Key == "": + return nil, errors.New("send_draft needs a sign-in to key its state on") + case cfg.Origin == "": + return nil, errors.New("send_draft needs the HEY origin") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + key, err := cfg.State.ConfirmKey(context.Background()) + if err != nil { + return nil, err + } + return &sender{cfg: cfg, key: key}, nil +} + +const sendDescription = "Send a saved draft: the only way hey mcp delivers mail. " + + "Each send asks the person to confirm it in this app, showing who it goes to (To, Cc and Bcc), the subject, attachments and the body. " + + "The confirmation is bound to that exact draft, so a draft changed after it was approved isn't sent, and each draft can be sent through hey mcp once. " + + "A client that can't show a confirmation gets the draft's link in HEY instead, and nothing is sent: saying yes in the chat doesn't send it. " + + "Drafts to someone this account hasn't emailed before are capped per day, and refused for a while after reading mail that may be from an unscreened sender. " + + "A draft that is already scheduled isn't sent here. Actions: send_draft (params: draft_id, the id create_message or create_reply returned), describe." + +func (s *sender) tool() *mcp.Tool { + destructive, openWorld := true, true + return &mcp.Tool{ + Name: sendToolName, + Title: "Send a HEY draft", + Description: sendDescription, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []any{sendDraftName, gateway.DescribeAction}}, + "params": map[string]any{ + "type": "object", + "properties": map[string]any{ + "draft_id": map[string]any{"type": []any{"integer", "string"}, "description": "The draft's id, from create_message, create_reply or list_drafts."}, + }, + }, + }, + "required": []any{"action"}, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Send a HEY draft", + ReadOnlyHint: false, + DestructiveHint: &destructive, + IdempotentHint: false, + OpenWorldHint: &openWorld, + }, + } +} + +func (s *sender) handle(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + Action string `json:"action"` + Params map[string]any `json:"params"` + } + if len(req.Params.Arguments) > 0 { + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return gateway.ErrorResult("invalid arguments: %v", err), nil + } + } + switch args.Action { + case gateway.DescribeAction: + return gateway.JSONResult(map[string]any{ + "action": sendDraftName, + "description": sendDescription, + "params": map[string]any{"draft_id": "integer (required)"}, + }) + case sendDraftName: + default: + return gateway.ErrorResult("unknown action %q in %s (actions: %s, %s)", args.Action, sendToolName, sendDraftName, gateway.DescribeAction), nil + } + + draftID, err := draftIDParam(args.Params) + if err != nil { + return gateway.ErrorResult("%v", err), nil + } + + // A host that can't show the person a confirmation can't send (D3). There is no + // "I confirm" parameter: a value the model supplies is what an injected + // instruction would supply. + if !canElicitForm(req.Session) { + return s.notSent(draftID, "This MCP client can't show a confirmation, so hey mcp can't send from it."), nil + } + + if req.Params.RequestState == "" { + return s.ask(ctx, draftID) + } + return s.confirmAndSend(ctx, draftID, req.Params.RequestState, req.Params.InputResponses) +} + +// ask is the first round: review the draft and ask the person to confirm it. +func (s *sender) ask(ctx context.Context, draftID int64) (*mcp.CallToolResult, error) { + snap, outcome, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, nil + } + + state := s.sign(confirmState{ + Kind: confirmKind, Method: confirmMethod, + Grant: s.cfg.Grant.Key, Origin: s.cfg.Origin, Draft: draftID, + Fingerprint: snap.fingerprint, + Expires: s.cfg.Now().Add(confirmTTL).Unix(), + }) + + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{confirmInputID: confirmElicitation(s.prompt(snap, outcome))}, + RequestState: state, + }, nil +} + +// confirmAndSend is the second round: the person's answer, then the checks again, the +// claim, and the one send. +func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState string, responses mcp.InputResponseMap) (*mcp.CallToolResult, error) { + state, err := s.verify(rawState) + if err != nil { + return gateway.ErrorResult("the confirmation for this send isn't valid (%v), so nothing was sent; call send_draft again to ask again", err), nil + } + if state.Draft != draftID || state.Grant != s.cfg.Grant.Key || state.Origin != s.cfg.Origin { + return gateway.ErrorResult("the confirmation was for a different draft or sign-in, so nothing was sent; call send_draft again"), nil + } + + answer, _ := responses[confirmInputID].(*mcp.ElicitResult) + if answer == nil || answer.Action != "accept" || answer.Content["confirm"] != true { + return s.notSent(draftID, "The send wasn't approved."), nil + } + + // The approval covers content, never a policy decision: the policy runs again. + snap, outcome, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, nil + } + if !hmac.Equal([]byte(snap.fingerprint), []byte(state.Fingerprint)) { + return gateway.ErrorResult("not sent: the draft changed since you approved it (its recipients, sender, subject, body or attachments differ). Call send_draft again to review it as it is now"), nil + } + + if err = s.cfg.State.claim(ctx, s.cfg.Origin, draftID, s.cfg.Grant.Key, outcome.newList, outcome.unseen, s.cfg.Now()); err != nil { + var refused *refusalError + if errors.As(err, &refused) { + return gateway.ErrorResult("%s", refused.reason), nil + } + return gateway.ErrorResult("not sent: hey mcp couldn't record the send (%v), so it didn't risk sending it twice. Send it from HEY", err), nil + } + + // From here the draft is claimed for good: the claim is never released, even when + // HEY refuses, so there's no rollback to get wrong (D2.3). + sent, err := s.cfg.Drafts.SendDraft(ctx, draftID, snap.content()) + if err != nil { + if refusedByHEY(err) { + return gateway.ErrorResult("HEY refused the send: %v. The draft is still a draft, and hey mcp won't send it again for 24 hours; send it from HEY: %s", err, s.editURL(draftID)), nil + } + return gateway.ErrorResult("the send may have gone out: %v. Check Sent in HEY before doing anything else; hey mcp won't send this draft again", err), nil + } + + result := map[string]any{"sent": true, "draft_id": draftID, "note": "Sent through HEY's undo window. The person approved it in this app."} + if sent != nil { + if sent.Id != 0 { + result["message_id"] = sent.Id + } + if sent.TopicId != 0 { + result["topic_id"] = sent.TopicId + } + result["delayed"] = sent.Delayed + } + return gateway.JSONResult(result) +} + +// notSent is D3's answer: not an error, a draft and where to send it. +func (s *sender) notSent(draftID int64, why string) *mcp.CallToolResult { + url := s.editURL(draftID) + result, _ := gateway.JSONResult(map[string]any{ + "sent": false, + "draft_id": draftID, + "edit_url": url, + "note": why + " Draft saved but not sent. Review and send it in HEY: " + url, + }) + return result +} + +func (s *sender) editURL(draftID int64) string { + return fmt.Sprintf("%s/messages/%d/edit", strings.TrimRight(s.cfg.Origin, "/"), draftID) +} + +func draftIDParam(params map[string]any) (int64, error) { + raw, ok := params["draft_id"] + if !ok { + return 0, errors.New("send_draft needs params.draft_id, the id create_message or create_reply returned") + } + var id int64 + switch v := raw.(type) { + case float64: + id = int64(v) + if float64(id) != v { + id = 0 + } + case string: + id, _ = strconv.ParseInt(v, 10, 64) + } + if id <= 0 { + return 0, fmt.Errorf("params.draft_id must be a draft's id, got %v", raw) + } + for key := range params { + if key != "draft_id" { + return 0, fmt.Errorf("send_draft takes only params.draft_id, not %q", key) + } + } + return id, nil +} + +// canElicitForm reports whether the client declared form elicitation. A client that +// declares the capability with neither mode is read as form, as the SDK does. +func canElicitForm(session *mcp.ServerSession) bool { + if session == nil { + return false + } + params := session.InitializeParams() + if params == nil || params.Capabilities == nil || params.Capabilities.Elicitation == nil { + return false + } + caps := params.Capabilities.Elicitation + return caps.Form != nil || caps.URL == nil +} + +func confirmElicitation(message string) *mcp.ElicitParams { + return &mcp.ElicitParams{ + Mode: "form", + Message: message, + RequestedSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "confirm": map[string]any{ + "type": "boolean", + "title": "Send this email", + "description": "Check to send it now. Anything else, or declining, sends nothing.", + }, + }, + "required": []any{"confirm"}, + }, + } +} + +// --- the draft as reviewed --- + +// draftSnapshot is a draft as send_draft fetched it, and what it would deliver. +type draftSnapshot struct { + id int64 + from generated.Contact + actingSenderID int64 + to, cc, bcc []generated.Contact + subject string + rawContent string + body reviewedBody + scheduled time.Time + fingerprint string +} + +// content is the delivery request: every field from the verified snapshot, so an edit +// landing after the final fetch is overwritten by what was approved (D2). HEY replaces +// the whole recipient set when addressed is present (haystack Entry::Addressed#address), +// so a Bcc added concurrently is dropped rather than delivered. +func (d *draftSnapshot) content() hey.DraftContent { + return hey.DraftContent{ + Subject: d.subject, + Content: d.rawContent, + To: emails(d.to), + CC: emails(d.cc), + BCC: emails(d.bcc), + ActingSenderID: d.actingSenderID, + } +} + +func (d *draftSnapshot) recipients() []generated.Contact { + return slices.Concat(d.to, d.cc, d.bcc) +} + +func emails(contacts []generated.Contact) []string { + out := make([]string, 0, len(contacts)) + for _, c := range contacts { + out = append(out, c.EmailAddress) + } + return out +} + +func normalizedAddress(address string) string { + return strings.ToLower(strings.TrimSpace(address)) +} + +func snapshotOf(edit *generated.MessageEditState) (*draftSnapshot, error) { + body, err := analyzeBody(edit.Content) + if err != nil { + return nil, err + } + snap := &draftSnapshot{ + id: edit.Id, + from: edit.Sender, + to: edit.Addressed.Directly, + cc: edit.Addressed.Copied, + bcc: edit.Addressed.Blindcopied, + subject: edit.Subject, + rawContent: edit.Content, + body: body, + scheduled: edit.ScheduledDeliveryAt, + } + // HEY omits sender when the draft is sent as its creator contact, and the identity + // the draft was saved as rides the send (cmd's draftContentFrom does the same). + if snap.from.Id == 0 { + snap.from = edit.Creator + } + snap.actingSenderID = snap.from.Id + + sorted := func(contacts []generated.Contact) []string { + addresses := emails(contacts) + for i := range addresses { + addresses[i] = normalizedAddress(addresses[i]) + } + slices.Sort(addresses) + return addresses + } + fingerprint, err := json.Marshal(map[string]any{ + "v": 1, + "draft_id": snap.id, + "from_id": snap.actingSenderID, + "from": normalizedAddress(snap.from.EmailAddress), + "to": sorted(snap.to), + "cc": sorted(snap.cc), + "bcc": sorted(snap.bcc), + "subject": snap.subject, + "attachments": body.attachments, + "body_sha256": body.hash, + "mode": "now", + "scheduled_at": nil, + }) + if err != nil { + return nil, err + } + sum := sha256.Sum256(fingerprint) + snap.fingerprint = hex.EncodeToString(sum[:]) + return snap, nil +} + +// reviewOutcome is what the policy found, for the prompt. +type reviewOutcome struct { + // unseen: the confirmation can't show everything the draft transmits. + unseen bool + newList []string + newAddresses map[string]bool + exposed bool + exposedBy string +} + +// review fetches the draft and applies the policy, returning a refusal as the call's +// result when the draft can't be sent here. +func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *reviewOutcome, *mcp.CallToolResult) { + refuse := func(format string, a ...any) (*draftSnapshot, *reviewOutcome, *mcp.CallToolResult) { + return nil, nil, gateway.ErrorResult(format, a...) + } + + edit, err := s.cfg.Drafts.GetEdit(ctx, draftID) + if err != nil { + var apiErr *hey.Error + if errors.As(err, &apiErr) && (apiErr.HTTPStatus == http.StatusNotFound || apiErr.HTTPStatus == http.StatusUnprocessableEntity) { + return refuse("not sent: %d isn't a draft HEY can send (it may have been sent, trashed or deleted)", draftID) + } + return refuse("not sent: couldn't read draft %d: %v", draftID, err) + } + // HEY serves a message's edit state only while it's a live draft (haystack + // MessagesController#ensure_revisable_entry: drafted and not delayed), so reading it + // is the check that it hasn't been trashed or delivered. + if edit == nil || edit.Id != draftID { + return refuse("not sent: %d isn't a draft HEY can send", draftID) + } + snap, err := snapshotOf(edit) + if err != nil { + return refuse("not sent: %v", err) + } + + recipients := snap.recipients() + switch { + case !snap.scheduled.IsZero(): + return refuse("not sent: this draft is already scheduled; change or send it in HEY: %s", s.editURL(draftID)) + case len(recipients) == 0: + return refuse("not sent: the draft has no recipients; add them with update_message (entry.addressed)") + case len(recipients) > maxRecipients: + return refuse("not sent: hey mcp sends to at most %d recipients across To, Cc and Bcc, and this draft has %d. Send it from HEY: %s", maxRecipients, len(recipients), s.editURL(draftID)) + case snap.body.remote != "": + return refuse("not sent: the draft's body has a reference a mail client loads by itself (%s), which can carry mailbox data to whoever hosts it. Send it from HEY: %s", snap.body.remote, s.editURL(draftID)) + } + for _, r := range recipients { + if normalizedAddress(r.EmailAddress) == "" { + return refuse("not sent: a recipient has no email address") + } + } + + newAddresses := s.newAddresses(ctx, snap) + exposed, by, err := s.cfg.State.exposure(ctx, s.cfg.Grant.Key, s.cfg.Now()) + if err != nil { + // Without the marker the server can't know, so it treats the grant as exposed + // (D6), and the claim would need the same file anyway. + return refuse("not sent: hey mcp couldn't read its send state (%v). Send it from HEY", err) + } + if exposed && len(newAddresses) > 0 { + return refuse("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft is addressed to someone you haven't emailed before (%s). Send it from HEY: %s", + by, int(exposureWindow/time.Minute), newAddresses[0], s.editURL(draftID)) + } + + unseen := !snap.body.lossless || len([]rune(snap.body.text)) > bodyPromptCap + if unseen && (len(newAddresses) > 0 || exposed) { + return refuse("not sent: the draft can't be fully reviewed here (it has an attachment, more than %d characters, or formatting that can hide text), and it goes to someone you haven't emailed before or this conversation read unscreened mail. Send it from HEY: %s", + bodyPromptCap, s.editURL(draftID)) + } + + sends, charged, err := s.cfg.State.budget(ctx, s.cfg.Grant.Key, s.cfg.Now()) + if err != nil { + return refuse("not sent: hey mcp couldn't read its send state (%v). Send it from HEY", err) + } + if sends >= maxSendsPerDay { + return refuse("not sent: hey mcp sends at most %d messages a day on one sign-in, and this one has reached it. Send it from HEY: %s", maxSendsPerDay, s.editURL(draftID)) + } + uncharged := 0 + for _, address := range newAddresses { + if !charged[address] { + uncharged++ + } + } + if len(charged)+uncharged > maxNewAddressesPerDay { + return refuse("not sent: hey mcp sends to at most %d new addresses a day on one sign-in, and this draft would go past it. Send it from HEY: %s", maxNewAddressesPerDay, s.editURL(draftID)) + } + + outcome := &reviewOutcome{unseen: unseen, newList: newAddresses, newAddresses: map[string]bool{}, exposed: exposed, exposedBy: by} + for _, address := range newAddresses { + outcome.newAddresses[address] = true + } + return snap, outcome, nil +} + +// --- known recipients (D4) --- + +// newAddresses returns the draft's recipients that aren't known, normalized and in +// order. Known means history the agent can't manufacture: mail this account sent to the +// address before the sign-in existed. Contact records don't count, since a model could +// create one. Anything send_draft can't prove known, a failed lookup included, is new. +func (s *sender) newAddresses(ctx context.Context, snap *draftSnapshot) []string { + var addresses []string + for _, r := range snap.recipients() { + address := normalizedAddress(r.EmailAddress) + if !slices.Contains(addresses, address) { + addresses = append(addresses, address) + } + } + + known := s.knownAddresses(ctx, addresses) + var fresh []string + for _, address := range addresses { + if !known[address] { + fresh = append(fresh, address) + } + } + return fresh +} + +// knownAddresses looks each address up in HEY's search for a message to it from one of +// this identity's addresses, created before the sign-in. The search only narrows: each +// match is checked here against the entry's own creator, time and recipients, so a +// search that matches loosely can't make an address known. +func (s *sender) knownAddresses(ctx context.Context, addresses []string) map[string]bool { + known := map[string]bool{} + if s.cfg.Grant.GrantedAt.IsZero() || len(addresses) == 0 { + return known + } + identity, err := s.cfg.Drafts.Identity(ctx) + if err != nil || identity == nil { + return known + } + mine := map[string]bool{} + if address := normalizedAddress(identity.PrimaryContact.EmailAddress); address != "" { + mine[address] = true + } + for _, sender := range identity.Senders { + if address := normalizedAddress(sender.EmailAddress); address != "" { + mine[address] = true + } + } + + for _, address := range addresses { + result, err := s.cfg.Drafts.Search(ctx, hey.SearchParams{To: address}) + if err != nil || result == nil { + continue + } + known[address] = sentBefore(result, address, mine, s.cfg.Grant.GrantedAt) + } + return known +} + +func sentBefore(result *generated.AdvancedSearchResult, address string, mine map[string]bool, before time.Time) bool { + for _, match := range result.Matches { + for _, entry := range match.Entries { + if !mine[normalizedAddress(entry.Creator.EmailAddress)] || entry.CreatedAt.IsZero() || !entry.CreatedAt.Before(before) { + continue + } + for _, r := range slices.Concat(entry.Addressed.Directly, entry.Addressed.Copied, entry.Addressed.Blindcopied) { + if normalizedAddress(r.EmailAddress) == address { + return true + } + } + } + } + return false +} + +// --- the signed confirmation state --- + +// confirmState is what the request state carries between the rounds: the draft's +// fingerprint, bound to the sign-in and the HEY, with an expiry. It is signed with the +// installation's key, because a 2026-07-28 client holds it between the rounds. +type confirmState struct { + Kind string `json:"k"` + Method string `json:"m"` + Grant string `json:"g"` + Origin string `json:"o"` + Draft int64 `json:"d"` + Fingerprint string `json:"f"` + Expires int64 `json:"e"` +} + +func (s *sender) sign(state confirmState) string { + // A struct of strings and integers always marshals. + payload, _ := json.Marshal(state) + mac := hmac.New(sha256.New, s.key) + mac.Write(payload) + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (s *sender) verify(raw string) (*confirmState, error) { + encoded, signature, ok := strings.Cut(raw, ".") + if !ok { + return nil, errors.New("malformed") + } + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, errors.New("malformed") + } + given, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return nil, errors.New("malformed") + } + mac := hmac.New(sha256.New, s.key) + mac.Write(payload) + if !hmac.Equal(given, mac.Sum(nil)) { + return nil, errors.New("signature doesn't match") + } + var state confirmState + if err := json.Unmarshal(payload, &state); err != nil { + return nil, errors.New("malformed") + } + if state.Kind != confirmKind || state.Method != confirmMethod { + return nil, errors.New("not a send confirmation") + } + if !s.cfg.Now().Before(time.Unix(state.Expires, 0)) { + return nil, fmt.Errorf("it expired; a confirmation lasts %d minutes", int(confirmTTL/time.Minute)) + } + return &state, nil +} diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go new file mode 100644 index 000000000..3b762364c --- /dev/null +++ b/internal/mcpserver/send_test.go @@ -0,0 +1,994 @@ +package mcpserver + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- fixtures --- + +var grantedAt = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + +const testOrigin = "https://app.hey.example" + +func contact(name, address string) generated.Contact { + return generated.Contact{Id: int64(len(address)), Name: name, EmailAddress: address} +} + +// fakeDrafts is HEY as send_draft sees it: one draft, an identity, and the mail this +// identity sent before the sign-in (what search finds). +type fakeDrafts struct { + mu sync.Mutex + edit generated.MessageEditState + editErr error + sends []hey.DraftContent + sendErr error + sentTo map[string]time.Time + searches int +} + +func newFakeDrafts() *fakeDrafts { + return &fakeDrafts{ + edit: generated.MessageEditState{ + Id: 5, + Subject: "Re: Q3 plan", + Content: "
Hi Ann, the numbers on page 2 moved since Tuesday.
", + Sender: generated.Contact{Id: 77, Name: "Jeremy Daer", EmailAddress: "jeremy@hey.example"}, + Addressed: generated.Addressed{Directly: []generated.Contact{contact("Ann Example", "ann@example.com")}}, + }, + sentTo: map[string]time.Time{"ann@example.com": grantedAt.Add(-48 * time.Hour)}, + } +} + +func (f *fakeDrafts) update(fn func(*generated.MessageEditState)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(&f.edit) +} + +func (f *fakeDrafts) sent() []hey.DraftContent { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.sends) +} + +func (f *fakeDrafts) GetEdit(_ context.Context, draftID int64) (*generated.MessageEditState, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.editErr != nil { + return nil, f.editErr + } + if draftID != f.edit.Id { + return nil, &hey.Error{HTTPStatus: http.StatusNotFound, Message: "not found"} + } + edit := f.edit + edit.Addressed = generated.Addressed{ + Directly: slices.Clone(f.edit.Addressed.Directly), + Copied: slices.Clone(f.edit.Addressed.Copied), + Blindcopied: slices.Clone(f.edit.Addressed.Blindcopied), + } + return &edit, nil +} + +func (f *fakeDrafts) SendDraft(_ context.Context, _ int64, draft hey.DraftContent) (*generated.SentMessage, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sends = append(f.sends, draft) + if f.sendErr != nil { + return nil, f.sendErr + } + return &generated.SentMessage{Id: 5, TopicId: 900}, nil +} + +func (f *fakeDrafts) Identity(context.Context) (*generated.Identity, error) { + return &generated.Identity{ + PrimaryContact: generated.Contact{EmailAddress: "jeremy@hey.example"}, + Senders: []generated.Sender{{Id: 77, EmailAddress: "jeremy@hey.example"}}, + }, nil +} + +func (f *fakeDrafts) Search(_ context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.searches++ + at, ok := f.sentTo[params.To] + if !ok { + return &generated.AdvancedSearchResult{}, nil + } + return &generated.AdvancedSearchResult{Matches: []generated.SearchMatch{{Entries: []generated.Entry{{ + CreatedAt: at, + Creator: generated.Contact{EmailAddress: "jeremy@hey.example"}, + Addressed: generated.Addressed{Directly: []generated.Contact{{EmailAddress: params.To}}}, + }}}}}, nil +} + +// clock is a settable time source. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +type sendRig struct { + drafts *fakeDrafts + state *SendState + clock *clock + api *fakeAPI + cfg SendConfig + server *Server +} + +func newSendRig(t *testing.T) *sendRig { + t.Helper() + state, err := OpenSendState(t.TempDir()) + if err != nil { + t.Fatal(err) + } + rig := &sendRig{drafts: newFakeDrafts(), state: state, clock: &clock{now: grantedAt.Add(24 * time.Hour)}, api: &fakeAPI{}} + rig.cfg = SendConfig{Drafts: rig.drafts, State: state, Grant: Grant{Key: "grant-1", GrantedAt: grantedAt}, Origin: testOrigin, Now: rig.clock.Now} + rig.server, err = New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + return rig +} + +// answer is how the person answers the confirmation; nil means the client declares no +// elicitation at all. +type answer func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) + +func approve(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil +} + +// connect opens a client session on the rig's server. version "" is the SDK's latest +// (2026-07-28, where the client carries the rounds itself); an older one has the +// server elicit within the call. manual turns the client's own round handling off. +func (r *sendRig) connect(t *testing.T, elicit answer, version string, manual bool) *mcp.ClientSession { + t.Helper() + opts := &mcp.ClientOptions{} + if elicit != nil { + opts.ElicitationHandler = elicit + } + if manual { + opts.MultiRoundTrip = &mcp.MultiRoundTripOptions{Disabled: true} + } + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := r.server.BuildMCPServer(slog.New(slog.DiscardHandler)).Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, opts) + session, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ProtocolVersion: version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// sendDraft calls send_draft on the rig's draft, 5. +func sendDraft(t *testing.T, session *mcp.ClientSession) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: sendToolName, + Arguments: map[string]any{"action": sendDraftName, "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func resultText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + return textContent(t, result) +} + +func mustContain(t *testing.T, text string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(text, want) { + t.Errorf("missing %q in:\n%s", want, text) + } + } +} + +var eras = []struct{ name, version string }{ + {"2026-07-28 client rounds", ""}, + {"2025-06-18 server elicitation", "2025-06-18"}, +} + +// --- the tool --- + +func TestHeySendIsServedWithHonestHints(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", false) + + var send *mcp.Tool + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if tool.Name == sendToolName { + send = tool + } + } + if send == nil { + t.Fatal("hey_send isn't served") + } + a := send.Annotations + if a == nil || a.ReadOnlyHint || a.IdempotentHint || a.DestructiveHint == nil || !*a.DestructiveHint || a.OpenWorldHint == nil || !*a.OpenWorldHint { + t.Errorf("annotations = %+v, want destructive, open world, not read-only, not idempotent", a) + } +} + +func TestHeySendIsNotServedReadOnlyOrWithoutSend(t *testing.T) { + rig := newSendRig(t) + for name, cfg := range map[string]Config{ + "no send config": {}, + "read-only": {ReadOnly: true, Send: &rig.cfg}, + "no threads": {Domains: []string{"boxes"}, Send: &rig.cfg}, + } { + t.Run(name, func(t *testing.T) { + srv, err := New(&fakeAPI{}, cfg) + if err != nil { + t.Fatal(err) + } + if srv.ServesSend() { + t.Error("hey_send is served") + } + }) + } +} + +// --- confirmation --- + +func TestSendDraftSendsOnlyWhatThePersonConfirmed(t *testing.T) { + for _, era := range eras { + t.Run(era.name, func(t *testing.T) { + rig := newSendRig(t) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + if len(rig.drafts.sent()) != 0 { + t.Error("sent before the person answered") + } + return approve(ctx, req) + }, era.version, false) + + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": true`, `"message_id": 5`) + mustContain(t, prompt, `Send this email from "Jeremy Daer" ?`, + `To: "Ann Example" `, "Cc: —", "Bcc: —", + "Subject: Re: Q3 plan", "Attached: —", "When: Now (undo window applies)", + " Hi Ann, the numbers on page 2 moved since Tuesday.") + + sent := rig.drafts.sent() + if len(sent) != 1 { + t.Fatalf("sent %d times, want once", len(sent)) + } + want := hey.DraftContent{Subject: "Re: Q3 plan", Content: rig.drafts.edit.Content, To: []string{"ann@example.com"}, CC: []string{}, BCC: []string{}, ActingSenderID: 77} + got := sent[0] + if got.Subject != want.Subject || got.Content != want.Content || !slices.Equal(got.To, want.To) || len(got.CC)+len(got.BCC) != 0 || got.ActingSenderID != want.ActingSenderID || got.Schedule != nil { + t.Errorf("sent %+v, want %+v", got, want) + } + }) + } +} + +func TestSendDraftWithoutApprovalSendsNothing(t *testing.T) { + answers := map[string]*mcp.ElicitResult{ + "decline": {Action: "decline"}, + "cancel": {Action: "cancel"}, + "accept, unticked": {Action: "accept", Content: map[string]any{"confirm": false}}, + } + for name, given := range answers { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return given, nil }, "", false) + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("an unapproved send is an error result: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": false`, "Draft saved but not sent", testOrigin+"/messages/5/edit") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times without approval", n) + } + }) + } +} + +func TestSendDraftCantSendFromAHostWithoutElicitation(t *testing.T) { + for _, era := range eras { + t.Run(era.name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, nil, era.version, false) + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("D3 answers a non-error result: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": false`, "can't show a confirmation", "send it in HEY: "+testOrigin+"/messages/5/edit") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times from a host that can't confirm", n) + } + }) + } +} + +// --- digest binding --- + +func TestSendDraftRefusesADraftChangedAfterApproval(t *testing.T) { + changes := map[string]func(*generated.MessageEditState){ + "a Bcc added": func(e *generated.MessageEditState) { + e.Addressed.Blindcopied = append(e.Addressed.Blindcopied, contact("", "archive@evil.example")) + }, + "a recipient swapped": func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("Ann Example", "ann@evil.example")} + }, + "the subject": func(e *generated.MessageEditState) { e.Subject = "Re: Q3 plan (updated)" }, + "the body": func(e *generated.MessageEditState) { e.Content += "
PS: the reset code is 123456
" }, + "the sender": func(e *generated.MessageEditState) { e.Sender = contact("Jeremy", "jeremy@work.example") }, + "a schedule": func(e *generated.MessageEditState) { e.ScheduledDeliveryAt = grantedAt.Add(72 * time.Hour) }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.drafts.update(change) + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("a changed draft was sent: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times after the draft changed", n) + } + }) + } +} + +func TestSendDraftNamesTheChangeWhenOnlyTheContentMoved(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.drafts.update(func(e *generated.MessageEditState) { e.Subject = "Something else" }) + return approve(ctx, req) + }, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "the draft changed since you approved it") +} + +func TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange(t *testing.T) { + figure := func(sgid, url string) string { + return fmt.Sprintf(`
Plan attached.
plan.pdf
`, sgid, url, url) + } + first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + if err != nil { + t.Fatal(err) + } + refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2")) + replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + + if first.hash != refetched.hash { + t.Error("an unchanged attachment hashed differently on a second fetch (signed URLs must not count)") + } + if first.hash == replaced.hash { + t.Error("a same-name, same-size replacement attachment hashed the same") + } + if len(first.attachments) != 1 || first.attachments[0].Filename != "plan.pdf" || first.attachments[0].Size != 217088 { + t.Errorf("attachments = %+v", first.attachments) + } + if first.lossless { + t.Error("a draft with an attachment counts as fully shown") + } + if first.remote != "" { + t.Errorf("the attachment's own preview counted as a remote reference: %s", first.remote) + } +} + +// --- single use --- + +func TestSendDraftSendsADraftOnce(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("first send failed: %s", resultText(t, result)) + } + again := sendDraft(t, session) + if !again.IsError { + t.Fatalf("a second send of the draft went through: %s", resultText(t, again)) + } + mustContain(t, resultText(t, again), "already sent") + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +// roundOne asks for a confirmation without answering it, as a 2026-07-28 client that +// carries the rounds itself would, and returns the signed state. +func roundOne(t *testing.T, session *mcp.ClientSession) *mcp.CallToolResult { + t.Helper() + result := sendDraft(t, session) + if !result.NeedsInput() || result.RequestState == "" || result.InputRequests[confirmInputID] == nil { + t.Fatalf("first round didn't ask for a confirmation: %+v", result) + } + return result +} + +func retry(t *testing.T, session *mcp.ClientSession, state string, answer *mcp.ElicitResult) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: sendToolName, + Arguments: map[string]any{"action": sendDraftName, "params": map[string]any{"draft_id": 5}}, + RequestState: state, + InputResponses: mcp.InputResponseMap{confirmInputID: answer}, + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestAReplayedConfirmationSendsNothing(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + + state := roundOne(t, session).RequestState + if result := retry(t, session, state, accepted); result.IsError { + t.Fatalf("confirmed send failed: %s", resultText(t, result)) + } + replayed := retry(t, session, state, accepted) + if !replayed.IsError { + t.Fatalf("a replayed confirmation sent again: %s", resultText(t, replayed)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +func TestTwoConfirmationsOfOneDraftSendItOnce(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + + first := roundOne(t, session).RequestState + rig.clock.advance(time.Second) + second := roundOne(t, session).RequestState + if first == second { + t.Fatal("the two rounds minted the same state, so this wouldn't test two confirmations") + } + if result := retry(t, session, first, accepted); result.IsError { + t.Fatalf("first confirmation failed: %s", resultText(t, result)) + } + if result := retry(t, session, second, accepted); !result.IsError { + t.Fatalf("a second confirmation of the same draft sent it again: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +func TestAForgedOrExpiredConfirmationSendsNothing(t *testing.T) { + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + cases := map[string]func(rig *sendRig, state string) string{ + "tampered payload": func(_ *sendRig, state string) string { + payload, signature, _ := strings.Cut(state, ".") + return payload[:len(payload)-2] + "AA." + signature + }, + "unsigned": func(_ *sendRig, state string) string { + payload, _, _ := strings.Cut(state, ".") + return payload + "." + }, + "made up": func(*sendRig, string) string { return "eyJrIjoic2VuZF9kcmFmdCJ9.c2lnbmF0dXJl" }, + "expired": func(rig *sendRig, state string) string { + rig.clock.advance(confirmTTL + time.Second) + return state + }, + "another sign-in's key": func(_ *sendRig, state string) string { + other := &sender{key: []byte("a different installation's key.."), cfg: SendConfig{Now: time.Now}} + payload, _, _ := strings.Cut(state, ".") + raw, _ := base64Decode(payload) + var st confirmState + _ = json.Unmarshal(raw, &st) + return other.sign(st) + }, + } + for name, forge := range cases { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + state := roundOne(t, session).RequestState + result := retry(t, session, forge(rig, state), accepted) + if !result.IsError { + t.Fatalf("sent on a forged confirmation: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +// --- recipients, exposure and caps --- + +func TestSendDraftFlagsANewRecipient(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Copied = []generated.Contact{contact("Bob Example", "bob@example.com")} + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, prompt, `Cc: "Bob Example" (new: never emailed before)`) + if strings.Contains(prompt, " (new") { + t.Error("a recipient emailed before the sign-in was flagged new") + } +} + +func TestMailSentAfterTheSignInDoesntMakeARecipientKnown(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentTo["ann@example.com"] = grantedAt.Add(time.Hour) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, " (new: never emailed before)") +} + +func TestAnUnscreenedReadRefusesSendsToNewRecipients(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "archive@evil.example")} + }) + session := rig.connect(t, approve, "", false) + + // Reading a thread may return an unscreened sender's words. + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil || read.IsError { + t.Fatalf("get_topic: %v %v", err, read) + } + + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("sent to a new recipient right after an unscreened read: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), "unscreened", "get_topic", "archive@evil.example") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + + // Once the window lapses, the send is a D4 send again: confirmed and flagged. + rig.clock.advance(exposureWindow + time.Second) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("still refused after the exposure window: %s", resultText(t, result)) + } +} + +func TestABoxReadDoesntExpose(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_boxes", + Arguments: map[string]any{"action": "get_imbox", "params": map[string]any{}}, + }); err != nil { + t.Fatal(err) + } + if result := sendDraft(t, session); result.IsError { + t.Fatalf("an Imbox read exposed the grant: %s", resultText(t, result)) + } +} + +func TestAnUnscreenedReadBetweenPromptAndApprovalRefusesTheSend(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + // Another conversation on this sign-in reads the Screener meanwhile. + if err := rig.state.MarkExposed(ctx, "grant-1", "get_clearances", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("an approval carried past an exposure: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestAnUnscreenedReadBetweenPromptAndApprovalRefusesUnseenContent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
Plan attached.
` + }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + if err := rig.state.MarkExposed(ctx, "grant-1", "get_message", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); !result.IsError { + t.Fatalf("an attachment went out on an approval an exposure overtook: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestAnExposedReadIsRefusedWhenItsMarkerCantBeWritten(t *testing.T) { + rig := newSendRig(t) + rig.state.fsync = func(*os.File) error { return errors.New("disk full") } + session := rig.connect(t, approve, "", false) + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil { + t.Fatal(err) + } + if !read.IsError || strings.Contains(textContent(t, read), `"ok":true`) { + t.Fatalf("content went out without its exposure marker: %v", read.Content) + } +} + +func TestSendDraftCapsRecipientsSendsAndNewAddresses(t *testing.T) { + t.Run("recipients per message", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + for i := range maxRecipients { + e.Addressed.Copied = append(e.Addressed.Copied, contact("", fmt.Sprintf("colleague%d@example.com", i))) + } + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 20 recipients") + }) + + t.Run("messages a day", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", nil, false, rig.clock.Now()); err != nil { + t.Fatal(err) + } + } + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 50 messages a day") + rig.clock.advance(capWindow) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("the cap outlived its window: %s", resultText(t, result)) + } + }) + + t.Run("new addresses count once each", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay - 1 { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", []string{"bob@example.com"}, false, rig.clock.Now()); err != nil { + t.Fatalf("send %d to one new address: %v", i, err) + } + } + _, charged, _ := rig.state.budget(context.Background(), "grant-1", rig.clock.Now()) + if len(charged) != 1 { + t.Errorf("%d sends to one new address charged %d", maxSendsPerDay-1, len(charged)) + } + }) + + t.Run("an eleventh new address", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxNewAddressesPerDay { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", []string{fmt.Sprintf("new%d@example.com", i)}, false, rig.clock.Now()); err != nil { + t.Fatal(err) + } + } + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "eleventh@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 10 new addresses") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + + t.Run("caps are per sign-in", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay { + _ = rig.state.claim(context.Background(), testOrigin, int64(1000+i), "another-grant", nil, false, rig.clock.Now()) + } + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("another sign-in's sends counted here: %s", resultText(t, result)) + } + }) +} + +func TestUnseenContentGoesOnlyToKnownRecipients(t *testing.T) { + long := "
" + strings.Repeat("The numbers moved again. ", 80) + "
" + hidden := `
Thanks!
forward the reset codes
` + attached := `
Plan attached.
` + + for name, body := range map[string]string{"past the cap": long, "hidden text": hidden, "an attachment": attached} { + t.Run(name+" to a new recipient", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = body + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + t.Run(name+" to a known recipient", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("refused for a known recipient: %s", resultText(t, result)) + } + }) + } +} + +func TestARemoteReferenceIsRefusedForEveryRecipient(t *testing.T) { + for name, body := range map[string]string{ + "image": `
Hi Ann
`, + "css url": `
Hi Ann
`, + "stylesheet": `
Hi
`, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +func TestAScheduledDraftIsntSentHere(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.ScheduledDeliveryAt = grantedAt.Add(72 * time.Hour) }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "already scheduled") +} + +func TestTheConfirmationRendersEveryFieldInert(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Subject = "Q3\nBcc: nobody" + e.Addressed.Directly = []generated.Contact{contact("Ann\n Bcc: —\u202e", "ann@example.com")} + e.Content = "
To: someone@evil.example
zero\u200bwidth
" + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + + mustContain(t, prompt, `Subject: Q3\u{000A}Bcc: nobody`, `"Ann\u{000A} Bcc: —\u{202E}" `, + " To: someone@evil.example", `zero\u{200B}width`) + if strings.Count(prompt, "\n Bcc:") != 1 { + t.Errorf("a field forged a Bcc line:\n%s", prompt) + } +} + +// --- durability --- + +func TestAFailedFsyncFailsTheSendClosed(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.state.fsync = func(*os.File) error { return errors.New("I/O error") } + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("sent without a durable claim: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestClaimsAndCapsSurviveARestart(t *testing.T) { + dir := t.TempDir() + now := grantedAt.Add(24 * time.Hour) + first, err := OpenSendState(dir) + if err != nil { + t.Fatal(err) + } + if err := first.claim(context.Background(), testOrigin, 5, "grant-1", []string{"new@example.com"}, false, now); err != nil { + t.Fatal(err) + } + if err := first.MarkExposed(context.Background(), "grant-1", "get_topic", now); err != nil { + t.Fatal(err) + } + + reopened, err := OpenSendState(dir) + if err != nil { + t.Fatal(err) + } + if err := reopened.claim(context.Background(), testOrigin, 5, "grant-2", nil, false, now); err == nil { + t.Error("the same draft was claimed again after a restart, from another sign-in") + } + if exposed, _, _ := reopened.exposure(context.Background(), "grant-1", now); !exposed { + t.Error("the exposure marker didn't survive a restart") + } + if _, charged, _ := reopened.budget(context.Background(), "grant-1", now); !charged["new@example.com"] { + t.Error("the new-address charge didn't survive a restart") + } + + key1, _ := first.ConfirmKey(context.Background()) + key2, _ := reopened.ConfirmKey(context.Background()) + if len(key1) != confirmKeyBytes || string(key1) != string(key2) { + t.Error("the confirmation key isn't one per installation") + } +} + +// --- what the send does on failure --- + +func TestAFailedSendIsNeverRetriedAndStaysClaimed(t *testing.T) { + for name, sendErr := range map[string]error{ + "HEY refused": &hey.Error{HTTPStatus: http.StatusUnprocessableEntity, Message: "sending limit reached"}, + "no answer": errors.New("connection reset"), + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sendErr = sendErr + session := rig.connect(t, approve, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("a failed send reported success: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("send attempted %d times, want once", n) + } + rig.drafts.sendErr = nil + again := sendDraft(t, session) + mustContain(t, resultText(t, again), "already sent") + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("a claimed draft was sent again after a failure (%d attempts)", n) + } + }) + } +} + +// --- exposure table --- + +// TestEveryServedReadIsClassifiedForExposure holds readExposes total: a read served +// without an entry exposes by default, but the table should say so on purpose. +func TestEveryServedReadIsClassifiedForExposure(t *testing.T) { + cat := loadForTest(t) + for _, d := range cat.Domains { + for _, op := range d.Operations { + if !op.ReadOnly { + continue + } + if _, ok := readExposes[op.ID]; !ok { + t.Errorf("%s (%s) is served but not classified in readExposes", op.ID, d.Key) + } + } + } +} + +func base64Decode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// The claim re-reads exposure itself, so a marker landing after the second round's +// review and before the claim still refuses a draft to someone new or with unseen +// content. +func TestTheClaimRereadsExposure(t *testing.T) { + rig := newSendRig(t) + now := rig.clock.Now() + if err := rig.state.MarkExposed(context.Background(), "grant-1", "get_topic", now); err != nil { + t.Fatal(err) + } + var refused *refusalError + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", []string{"new@example.com"}, false, now); !errors.As(err, &refused) { + t.Errorf("claimed a draft to someone new while exposed: %v", err) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, true, now); !errors.As(err, &refused) { + t.Errorf("claimed unseen content while exposed: %v", err) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, false, now); err != nil { + t.Errorf("refused a fully shown draft to known recipients while exposed: %v", err) + } +} + +// The caps are charged in the claim's own locked update, not only checked by the +// review before it, so two processes can't both spend the last of one. +func TestTheClaimEnforcesTheCaps(t *testing.T) { + ctx := context.Background() + rig := newSendRig(t) + now := rig.clock.Now() + var refused *refusalError + for i := range maxNewAddressesPerDay { + if err := rig.state.claim(ctx, testOrigin, int64(100+i), "grant-1", []string{fmt.Sprintf("new%d@example.com", i)}, false, now); err != nil { + t.Fatal(err) + } + } + if err := rig.state.claim(ctx, testOrigin, 200, "grant-1", []string{"eleventh@example.com"}, false, now); !errors.As(err, &refused) { + t.Errorf("claimed an eleventh new address: %v", err) + } + for i := maxNewAddressesPerDay; i < maxSendsPerDay; i++ { + if err := rig.state.claim(ctx, testOrigin, int64(100+i), "grant-1", nil, false, now); err != nil { + t.Fatal(err) + } + } + if err := rig.state.claim(ctx, testOrigin, 300, "grant-1", nil, false, now); !errors.As(err, &refused) { + t.Errorf("claimed a fifty-first send: %v", err) + } +} + +// Each fsync is load-bearing: the new state file's before the rename, and the +// directory's after it. +func TestEitherFailedFsyncFailsTheClaim(t *testing.T) { + for name, failDir := range map[string]bool{"the file": false, "the directory": true} { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.state.fsync = func(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + if info.IsDir() == failDir { + return errors.New("I/O error") + } + return f.Sync() + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, false, rig.clock.Now()); err == nil { + t.Errorf("a claim whose fsync of %s failed was acknowledged", name) + } + }) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go new file mode 100644 index 000000000..04978c10a --- /dev/null +++ b/internal/mcpserver/sendbody.go @@ -0,0 +1,259 @@ +package mcpserver + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// What send_draft reads out of a draft's body (sending-safety D2): the text the +// confirmation shows, whether that text is everything the mail carries, its +// attachments, any reference a mail client would load by itself, and the hash the +// confirmation binds. +// +// The body is the Trix editing HTML GetMessageEdit serves. Its attachments are +//
(or ) elements whose +// JSON names the blob's sgid, filename, size and type beside a signed URL. + +// reviewedBody is one draft body, analyzed. +type reviewedBody struct { + // text is the plain-text rendering the confirmation shows, links with their targets. + text string + // lossless reports whether text shows everything the body transmits. Any tag or + // attribute outside the plain set below, which may style text out of sight, makes it + // false; so does any attachment, since a name and a size don't show a file. + lossless bool + // attachments are the body's attachments, in order. + attachments []attachment + // remote names the first reference a mail client would fetch on open, or "". + remote string + // hash is the sha256 of the body with each attachment reduced to its identity and + // the volatile attributes (signed URLs) dropped, so two fetches of an unchanged draft + // hash alike and a changed attachment doesn't. + hash string +} + +type attachment struct { + SGID string `json:"sgid"` + Filename string `json:"filename"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Caption string `json:"caption,omitempty"` +} + +// plainTags are the elements Trix writes for text, which the text rendering shows in +// full. Anything else is treated as able to hide content. +var plainTags = map[atom.Atom]bool{ + atom.Div: true, atom.P: true, atom.Br: true, atom.Strong: true, atom.B: true, + atom.Em: true, atom.I: true, atom.Del: true, atom.S: true, atom.Strike: true, + atom.A: true, atom.Ul: true, atom.Ol: true, atom.Li: true, atom.Blockquote: true, + atom.Pre: true, atom.H1: true, atom.Code: true, + // html.Parse wraps a fragment in these. + atom.Html: true, atom.Head: true, atom.Body: true, +} + +// blockTags end a line in the text rendering. +var blockTags = map[atom.Atom]bool{ + atom.Div: true, atom.P: true, atom.Li: true, atom.Blockquote: true, atom.Pre: true, atom.H1: true, + atom.Ul: true, atom.Ol: true, +} + +// loadingTags fetch something when a mail client renders them, or can make it. +var loadingTags = map[atom.Atom]bool{ + atom.Img: true, atom.Image: true, atom.Iframe: true, atom.Frame: true, atom.Video: true, + atom.Audio: true, atom.Source: true, atom.Track: true, atom.Embed: true, atom.Object: true, + atom.Link: true, atom.Picture: true, atom.Svg: true, atom.Style: true, atom.Script: true, + atom.Meta: true, atom.Base: true, atom.Input: true, +} + +// loadingAttributes name a URL a client loads by itself. +var loadingAttributes = []string{"src", "srcset", "background", "poster", "lowsrc", "dynsrc", "data", "xlink:href", "action", "formaction"} + +// analyzeBody reads a draft's raw content. +func analyzeBody(content string) (reviewedBody, error) { + nodes, err := html.ParseFragment(strings.NewReader(content), &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}) + if err != nil { + return reviewedBody{}, fmt.Errorf("read the draft's body: %w", err) + } + + w := &bodyWalker{lossless: true} + for _, n := range nodes { + w.walk(n) + } + + sum := sha256.Sum256([]byte(w.canonical.String())) + return reviewedBody{ + text: tidyText(w.text.String()), + lossless: w.lossless && len(w.attachments) == 0, + attachments: w.attachments, + remote: w.remote, + hash: hex.EncodeToString(sum[:]), + }, nil +} + +type bodyWalker struct { + text, canonical strings.Builder + lossless bool + attachments []attachment + remote string +} + +func (w *bodyWalker) walk(n *html.Node) { + switch n.Type { + case html.TextNode: + w.text.WriteString(n.Data) + w.canonical.WriteString(strconv.Quote(n.Data)) + return + case html.CommentNode: + w.lossless = false + w.canonical.WriteString("") + return + case html.ElementNode: + default: + for c := n.FirstChild; c != nil; c = c.NextSibling { + w.walk(c) + } + return + } + + if a, ok := attachmentOf(n); ok { + // The attachment's own markup (the preview image on a signed URL, the caption + // Trix derives) is HEY's, not the sender's: reduce it to the blob's identity. + w.attachments = append(w.attachments, a) + identity, _ := json.Marshal(a) + w.canonical.WriteString("") + w.text.WriteString("\n[attachment: " + a.Filename + "]\n") + return + } + + if !plainTags[n.DataAtom] { + w.lossless = false + } + if loadingTags[n.DataAtom] && w.remote == "" { + w.remote = "<" + n.Data + ">" + } + + attrs := slices.Clone(n.Attr) + slices.SortFunc(attrs, func(a, b html.Attribute) int { return strings.Compare(a.Key, b.Key) }) + w.canonical.WriteString("<" + n.Data) + for _, attr := range attrs { + w.canonical.WriteString(" " + attr.Key + "=" + strconv.Quote(attr.Val)) + if n.DataAtom != atom.A || attr.Key != "href" { + w.lossless = false + } + if w.remote == "" && loadsByItself(attr) { + w.remote = attr.Key + " on <" + n.Data + ">" + } + } + w.canonical.WriteString(">") + + if n.DataAtom == atom.Br { + w.text.WriteString("\n") + } + if n.DataAtom == atom.Li { + w.text.WriteString("- ") + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + w.walk(c) + } + if n.DataAtom == atom.A { + if href := attrValue(n, "href"); href != "" { + // A link doesn't load itself, so it's seen content once its target shows. + w.text.WriteString(" <" + href + ">") + } + } + if blockTags[n.DataAtom] { + w.text.WriteString("\n") + } + w.canonical.WriteString("") +} + +// loadsByItself reports whether an attribute makes a mail client fetch a URL on open: +// a loading attribute, or a style that names one. +func loadsByItself(attr html.Attribute) bool { + key := strings.ToLower(attr.Key) + if slices.Contains(loadingAttributes, key) { + return true + } + if key == "style" { + value := strings.ToLower(attr.Val) + return strings.Contains(value, "url(") || strings.Contains(value, "image-set(") || strings.Contains(value, "@import") + } + return false +} + +// attachmentOf reads a Trix attachment element: a figure carrying data-trix-attachment, +// or an action-text-attachment. +func attachmentOf(n *html.Node) (attachment, bool) { + if n.DataAtom == atom.Figure { + raw := attrValue(n, "data-trix-attachment") + if raw == "" { + return attachment{}, false + } + var data struct { + SGID string `json:"sgid"` + Filename string `json:"filename"` + Filesize any `json:"filesize"` + ContentType string `json:"contentType"` + } + _ = json.Unmarshal([]byte(raw), &data) + var attributes struct { + Caption string `json:"caption"` + } + _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) + return attachment{SGID: data.SGID, Filename: data.Filename, Size: sizeOf(data.Filesize), ContentType: data.ContentType, Caption: attributes.Caption}, true + } + if n.Data == "action-text-attachment" { + size, _ := strconv.ParseInt(attrValue(n, "filesize"), 10, 64) + return attachment{SGID: attrValue(n, "sgid"), Filename: attrValue(n, "filename"), Size: size, ContentType: attrValue(n, "content-type"), Caption: attrValue(n, "caption")}, true + } + return attachment{}, false +} + +func sizeOf(v any) int64 { + switch size := v.(type) { + case float64: + return int64(size) + case string: + n, _ := strconv.ParseInt(size, 10, 64) + return n + default: + return 0 + } +} + +func attrValue(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +// tidyText trims trailing space from each line and collapses runs of blank lines. +func tidyText(s string) string { + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + var out []string + blank := 0 + for _, line := range lines { + line = strings.TrimRight(line, " \t") + if line == "" { + blank++ + if blank > 1 { + continue + } + } else { + blank = 0 + } + out = append(out, line) + } + return strings.Trim(strings.Join(out, "\n"), "\n") +} diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go new file mode 100644 index 000000000..e9d1cfbfa --- /dev/null +++ b/internal/mcpserver/sendprompt.go @@ -0,0 +1,158 @@ +package mcpserver + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +// The confirmation the person reads (sending-safety D2). Every recipient field is shown, +// Bcc included and "—" when empty. Every dynamic field is rendered inert: the subject, +// names, attachment names and body were chosen by a model or a sender, and the host shows +// the prompt as plain text, so a newline in a display name could forge a Bcc line and a +// bidi override could reorder an address. Header fields are single lines, capped, and +// come first in a fixed order; every body line is indented beneath "Body:". This is +// presentation only: the fingerprint covers the raw values. + +func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { + var b strings.Builder + if outcome.exposed { + fmt.Fprintf(&b, "This conversation read mail that may be from an unscreened sender (%s) in the last few minutes. Check that you asked for this email.\n\n", inertLine(outcome.exposedBy)) + } + fmt.Fprintf(&b, "Send this email from %s?\n", recipientLine(snap.from, nil)) + b.WriteString(" When: Now (undo window applies)\n") + fmt.Fprintf(&b, " To: %s\n", recipientList(snap.to, outcome.newAddresses)) + fmt.Fprintf(&b, " Cc: %s\n", recipientList(snap.cc, outcome.newAddresses)) + fmt.Fprintf(&b, " Bcc: %s\n", recipientList(snap.bcc, outcome.newAddresses)) + fmt.Fprintf(&b, " Subject: %s\n", inertLine(snap.subject)) + fmt.Fprintf(&b, " Attached: %s\n", attachmentList(snap.body.attachments)) + b.WriteString(" Body:\n") + + body := inertBody(snap.body.text) + total := utf8.RuneCountInString(body) + if total > bodyPromptCap { + body = string([]rune(body)[:bodyPromptCap]) + " …" + } + for line := range strings.SplitSeq(body, "\n") { + b.WriteString(" " + line + "\n") + } + if total > bodyPromptCap { + fmt.Fprintf(&b, " [first %s characters of %s · open the full draft in HEY: %s]\n", thousands(bodyPromptCap), thousands(total), s.editURL(snap.id)) + } + if !snap.body.lossless { + fmt.Fprintf(&b, " Some of this draft can't be shown here; open it in HEY to see all of it: %s\n", s.editURL(snap.id)) + } + if len(outcome.newAddresses) > 0 { + b.WriteString("\nRecipients marked (new) haven't been emailed from this account before hey mcp was signed in.\n") + } + return b.String() +} + +// recipientLine shows a recipient as "Name"
, so the address the mail goes to is +// always visible beside whatever name the draft carries. +func recipientLine(c generated.Contact, fresh map[string]bool) string { + address := "<" + inertLine(c.EmailAddress) + ">" + line := address + if name := strings.TrimSpace(c.Name); name != "" && !strings.EqualFold(name, c.EmailAddress) { + line = `"` + strings.ReplaceAll(inertLine(name), `"`, `\"`) + `" ` + address + } + if fresh[normalizedAddress(c.EmailAddress)] { + line += " (new: never emailed before)" + } + return line +} + +func recipientList(contacts []generated.Contact, fresh map[string]bool) string { + if len(contacts) == 0 { + return "—" + } + lines := make([]string, 0, len(contacts)) + for _, c := range contacts { + lines = append(lines, recipientLine(c, fresh)) + } + return strings.Join(lines, ", ") +} + +func attachmentList(attachments []attachment) string { + if len(attachments) == 0 { + return "—" + } + names := make([]string, 0, len(attachments)) + for _, a := range attachments { + names = append(names, fmt.Sprintf("%s (%s)", inertLine(a.Filename), humanSize(a.Size))) + } + return strings.Join(names, ", ") +} + +func humanSize(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%d KB", n/(1<<10)) + default: + return fmt.Sprintf("%d bytes", n) + } +} + +func thousands(n int) string { + s := fmt.Sprint(n) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +} + +// inertLine renders a header field: one line, every control and invisible character +// escaped, capped at headerCap characters. +func inertLine(s string) string { + out := escapeInvisible(s, false) + if utf8.RuneCountInString(out) > headerCap { + out = string([]rune(out)[:headerCap]) + "…" + } + return out +} + +// inertBody renders the body: plain newlines kept, everything else escaped. +func inertBody(s string) string { + return escapeInvisible(s, true) +} + +// escapeInvisible replaces C0 and C1 controls, bidi embeddings, overrides, isolates and +// marks, zero-width characters and the Unicode line and paragraph separators with a +// visible \u{…} escape. keepNewlines keeps "\n" as a line break. +func escapeInvisible(s string, keepNewlines bool) string { + var b strings.Builder + for _, r := range s { + if r == '\n' && keepNewlines { + b.WriteRune(r) + continue + } + if invisible(r) { + fmt.Fprintf(&b, `\u{%04X}`, r) + continue + } + b.WriteRune(r) + } + return b.String() +} + +func invisible(r rune) bool { + switch { + case r < 0x20, r == 0x7F, r >= 0x80 && r <= 0x9F: + return true + case r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069: + return true + case r == 0x200E, r == 0x200F, r == 0x061C: + return true + case r >= 0x200B && r <= 0x200D, r == 0x2060, r == 0xFEFF, r == 0x00AD, r == 0x180E: + return true + case r == 0x2028, r == 0x2029: + return true + case r == utf8.RuneError: + return true + } + return false +} diff --git a/internal/mcpserver/sendstate.go b/internal/mcpserver/sendstate.go new file mode 100644 index 000000000..be43ca877 --- /dev/null +++ b/internal/mcpserver/sendstate.go @@ -0,0 +1,386 @@ +package mcpserver + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "sync" + "time" + + "github.com/gofrs/flock" +) + +// SendState is send_draft's state on stdio (sending-safety D2.3, D4, D6; roadmap +// "Implementation requirements"): the per-draft send claims, each grant's caps and +// new-address membership, and each grant's exposure marker, in one JSON file, with the +// confirmation key beside it. +// +// Every read-check-write runs under an exclusive OS file lock on a separate lock file +// that is never renamed or replaced, held across the whole read, check, write and +// rename: two hey mcp processes can't both claim one draft or both spend the last of a +// cap. The new file is fsynced before the rename and its directory after it, under the +// lock, and a failed fsync fails the operation, so an acknowledged claim survives a +// crash. A lock that can't be taken within lockTimeout fails closed. +type SendState struct { + dir string + // lockTimeout bounds the wait for another process's update. + lockTimeout time.Duration + // fsync syncs a file or directory; a seam so a test can make it fail. + fsync func(*os.File) error + + mu sync.Mutex +} + +const ( + sendStateFile = "mcp-send-state.json" + sendStateLock = "mcp-send-state.lock" + confirmKeyFile = "mcp-confirm.key" + confirmKeyBytes = 32 +) + +// claimTTL is how long a claimed draft stays claimed: well past the confirmation's TTL +// and HEY's undo window (D2.3). +const claimTTL = 24 * time.Hour + +// capWindow is the rolling window the per-grant caps count over (D4). +const capWindow = 24 * time.Hour + +// OpenSendState opens (creating) the send state directory. +func OpenSendState(dir string) (*SendState, error) { + if dir == "" { + return nil, errors.New("no state directory for hey mcp's send safety state") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + return &SendState{dir: dir, lockTimeout: 5 * time.Second, fsync: (*os.File).Sync}, nil +} + +// sendStateData is the state file's content. +type sendStateData struct { + Version int `json:"version"` + // Claims maps a claimed draft (claimKey) to when it was claimed, Unix seconds. A + // claim is never released early (D2.3). + Claims map[string]int64 `json:"claims"` + // Grants holds each grant's caps and exposure, by grant key. + Grants map[string]*grantState `json:"grants"` +} + +type grantState struct { + // Sends are when each confirmed send was claimed, Unix seconds. + Sends []int64 `json:"sends,omitempty"` + // NewAddresses are the new addresses charged in the window, each with when it was + // first charged: a send is charged only for new addresses not already here (D4). + NewAddresses map[string]int64 `json:"new_addresses,omitempty"` + // ExposedUntil is when the exposure window ends, Unix seconds (D6). + ExposedUntil int64 `json:"exposed_until,omitempty"` + // ExposedBy names the read that last exposed the grant. + ExposedBy string `json:"exposed_by,omitempty"` +} + +// claimKey is a draft's claim: the HEY it lives on and its id, and nothing else, so +// every confirmation of that draft contends for one claim whatever its grant, content +// or schedule (D2.3). +func claimKey(origin string, draftID int64) string { + return fmt.Sprintf("%s#%d", origin, draftID) +} + +func (d *sendStateData) grant(key string) *grantState { + g := d.Grants[key] + if g == nil { + g = &grantState{} + d.Grants[key] = g + } + if g.NewAddresses == nil { + g.NewAddresses = map[string]int64{} + } + return g +} + +// prune drops what has aged out of every window. +func (d *sendStateData) prune(now time.Time) { + for key, at := range d.Claims { + if now.Sub(time.Unix(at, 0)) >= claimTTL { + delete(d.Claims, key) + } + } + for key, g := range d.Grants { + g.Sends = slices.DeleteFunc(g.Sends, func(at int64) bool { return now.Sub(time.Unix(at, 0)) >= capWindow }) + for address, at := range g.NewAddresses { + if now.Sub(time.Unix(at, 0)) >= capWindow { + delete(g.NewAddresses, address) + } + } + if g.ExposedUntil != 0 && !now.Before(time.Unix(g.ExposedUntil, 0)) { + g.ExposedUntil, g.ExposedBy = 0, "" + } + if len(g.Sends) == 0 && len(g.NewAddresses) == 0 && g.ExposedUntil == 0 { + delete(d.Grants, key) + } + } +} + +// update runs fn on the state under the lock and writes the result durably. fn's error +// leaves the file as it was. +func (s *SendState) update(ctx context.Context, fn func(*sendStateData) error) error { + return s.locked(ctx, func() error { + data, err := s.read() + if err != nil { + return err + } + if err := fn(data); err != nil { + return err + } + return s.write(data) + }) +} + +// view runs fn on the state under the lock, writing nothing. +func (s *SendState) view(ctx context.Context, fn func(*sendStateData)) error { + return s.locked(ctx, func() error { + data, err := s.read() + if err != nil { + return err + } + fn(data) + return nil + }) +} + +func (s *SendState) locked(ctx context.Context, fn func() error) error { + // A file lock belongs to an open file, not a goroutine, so the mutex is what makes + // it exclusive inside this process. + s.mu.Lock() + defer s.mu.Unlock() + + lock := flock.New(filepath.Join(s.dir, sendStateLock)) + waiting, cancel := context.WithTimeout(ctx, s.lockTimeout) + defer cancel() + locked, err := lock.TryLockContext(waiting, 25*time.Millisecond) + if err != nil { + return fmt.Errorf("could not lock hey mcp's send state: %w", err) + } + if !locked { + return errors.New("hey mcp's send state is locked by another process") + } + defer func() { _ = lock.Unlock() }() + return fn() +} + +func (s *SendState) read() (*sendStateData, error) { + data := &sendStateData{Version: 1, Claims: map[string]int64{}, Grants: map[string]*grantState{}} + raw, err := os.ReadFile(filepath.Join(s.dir, sendStateFile)) + if errors.Is(err, os.ErrNotExist) { + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read hey mcp's send state: %w", err) + } + if err := json.Unmarshal(raw, data); err != nil { + // A state file that doesn't parse can't say what was claimed or charged. + return nil, fmt.Errorf("hey mcp's send state %s is unreadable: %w", filepath.Join(s.dir, sendStateFile), err) + } + if data.Claims == nil { + data.Claims = map[string]int64{} + } + if data.Grants == nil { + data.Grants = map[string]*grantState{} + } + return data, nil +} + +// write replaces the state file: a temporary file fsynced, renamed into place, and the +// directory fsynced. Any failure fails the update. +func (s *SendState) write(data *sendStateData) error { + raw, err := json.Marshal(data) + if err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, sendStateFile+".*.tmp") + if err != nil { + return fmt.Errorf("write hey mcp's send state: %w", err) + } + tmpPath := tmp.Name() + keep := false + defer func() { + if !keep { + _ = os.Remove(tmpPath) //nolint:gosec // G703: path from os.CreateTemp + } + }() + + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + return fmt.Errorf("write hey mcp's send state: %w", err) + } + if err := s.fsync(tmp); err != nil { + _ = tmp.Close() + return fmt.Errorf("fsync hey mcp's send state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write hey mcp's send state: %w", err) + } + if err := os.Rename(tmpPath, filepath.Join(s.dir, sendStateFile)); err != nil { //nolint:gosec // G703: paths built from the state directory + return fmt.Errorf("replace hey mcp's send state: %w", err) + } + keep = true + return s.syncDir() +} + +// syncDir fsyncs the state directory, so the rename itself is durable. Windows can't +// open a directory for syncing; NTFS journals the rename's metadata. +func (s *SendState) syncDir() error { + if runtime.GOOS == "windows" { + return nil + } + dir, err := os.Open(s.dir) + if err != nil { + return fmt.Errorf("fsync hey mcp's state directory: %w", err) + } + defer dir.Close() + if err := s.fsync(dir); err != nil { + return fmt.Errorf("fsync hey mcp's state directory: %w", err) + } + return nil +} + +// ConfirmKey is the installation's confirmation key, generated once under the lock and +// kept beside the state, never regenerated per process (D4). +func (s *SendState) ConfirmKey(ctx context.Context) ([]byte, error) { + var key []byte + err := s.locked(ctx, func() error { + path := filepath.Join(s.dir, confirmKeyFile) + raw, err := os.ReadFile(path) //nolint:gosec // G304: path built from the state directory + if err == nil { + decoded, decodeErr := hex.DecodeString(string(raw)) + if decodeErr != nil || len(decoded) != confirmKeyBytes { + return fmt.Errorf("hey mcp's confirmation key %s is malformed; remove it to make a new one", path) + } + key = decoded + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read hey mcp's confirmation key: %w", err) + } + + fresh := make([]byte, confirmKeyBytes) + if _, randErr := rand.Read(fresh); randErr != nil { + return randErr + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // G304: path built from the state directory + if err != nil { + return fmt.Errorf("create hey mcp's confirmation key: %w", err) + } + if _, err := file.WriteString(hex.EncodeToString(fresh)); err != nil { + _ = file.Close() + return err + } + if err := s.fsync(file); err != nil { + _ = file.Close() + return fmt.Errorf("fsync hey mcp's confirmation key: %w", err) + } + if err := file.Close(); err != nil { + return err + } + key = fresh + return s.syncDir() + }) + return key, err +} + +// exposureWindow is how long an unscreened read tightens sends (D6). +const exposureWindow = 10 * time.Minute + +// MarkExposed records that the grant read content that may be an unscreened sender's, +// restarting its exposure window. It runs before that content is returned. +func (s *SendState) MarkExposed(ctx context.Context, grantKey, by string, now time.Time) error { + return s.update(ctx, func(d *sendStateData) error { + d.prune(now) + g := d.grant(grantKey) + g.ExposedUntil = now.Add(exposureWindow).Unix() + g.ExposedBy = by + return nil + }) +} + +// exposure reports whether the grant is exposed now, and by which read. +func (s *SendState) exposure(ctx context.Context, grantKey string, now time.Time) (bool, string, error) { + var exposed bool + var by string + err := s.view(ctx, func(d *sendStateData) { + d.prune(now) + if g := d.Grants[grantKey]; g != nil && g.ExposedUntil != 0 { + exposed, by = true, g.ExposedBy + } + }) + return exposed, by, err +} + +// budget is what a grant has spent in the window: its sends, and the new addresses +// already charged. +func (s *SendState) budget(ctx context.Context, grantKey string, now time.Time) (int, map[string]bool, error) { + var sends int + charged := map[string]bool{} + err := s.view(ctx, func(d *sendStateData) { + d.prune(now) + if g := d.Grants[grantKey]; g != nil { + sends = len(g.Sends) + for address := range g.NewAddresses { + charged[address] = true + } + } + }) + return sends, charged, err +} + +// refusalError is a send the state refused: the reason is the in-band error. +type refusalError struct{ reason string } + +func (r *refusalError) Error() string { return r.reason } + +// claim takes a draft's send claim and charges the grant's caps, in one locked update, +// or refuses. It re-reads exposure there: a draft with a new recipient, or with content +// the confirmation couldn't show in full (unseen), is refused if an unscreened read +// landed since the policy was checked (D2, D6). Nothing is written when it refuses. +func (s *SendState) claim(ctx context.Context, origin string, draftID int64, grantKey string, newAddresses []string, unseen bool, now time.Time) error { + return s.update(ctx, func(d *sendStateData) error { + d.prune(now) + key := claimKey(origin, draftID) + if _, taken := d.Claims[key]; taken { + return &refusalError{"this draft was already sent, or a send of it is in progress, through hey mcp in the last 24 hours, so it wasn't sent again. Check Sent in HEY. To send it anyway, send it from HEY"} + } + + g := d.grant(grantKey) + if g.ExposedUntil != 0 && len(newAddresses) > 0 { + return &refusalError{fmt.Sprintf("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft is addressed to someone you haven't emailed before (%s). Send it from HEY", g.ExposedBy, int(exposureWindow/time.Minute), newAddresses[0])} + } + if g.ExposedUntil != 0 && unseen { + return &refusalError{fmt.Sprintf("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft can't be fully reviewed in the confirmation. Send it from HEY", g.ExposedBy, int(exposureWindow/time.Minute))} + } + if len(g.Sends) >= maxSendsPerDay { + return &refusalError{fmt.Sprintf("not sent: hey mcp sends at most %d messages a day on one sign-in, and this one has reached it. Send it from HEY", maxSendsPerDay)} + } + var charge []string + for _, address := range newAddresses { + if _, charged := g.NewAddresses[address]; !charged && !slices.Contains(charge, address) { + charge = append(charge, address) + } + } + if len(g.NewAddresses)+len(charge) > maxNewAddressesPerDay { + return &refusalError{fmt.Sprintf("not sent: hey mcp sends to at most %d new addresses a day on one sign-in, and this draft would go past it. Send it from HEY", maxNewAddressesPerDay)} + } + + d.Claims[key] = now.Unix() + g.Sends = append(g.Sends, now.Unix()) + for _, address := range charge { + g.NewAddresses[address] = now.Unix() + } + return nil + }) +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index a9c7a65ca..3f8be20ae 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -1,8 +1,10 @@ package mcpserver import ( + "context" "fmt" "log/slog" + "slices" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -24,12 +26,16 @@ type Config struct { // Domains narrows the served domains by key ("boxes", "search", ...). // Empty means all. Unknown keys are a startup error — fail closed. Domains []string + // Send serves send_draft in its own hey_send tool (send.go), when the threads + // domain is served and the server isn't read-only. Nil serves no way to send. + Send *SendConfig } // Server wraps the toolkit gateway serving hey's derived catalog, dispatching // through the CLI's authenticated SDK client. type Server struct { - gw *gateway.Server + gw *gateway.Server + sender *sender } // New derives the catalog and hands it to the gateway, which applies the @@ -47,16 +53,40 @@ func New(api API, cfg Config) (*Server, error) { return nil, fmt.Errorf("hold sending operations to drafts: %w", err) } + var send *sender + if cfg.Send != nil && !cfg.ReadOnly && servesDomain(cfg.Domains, "threads") { + if send, err = newSender(*cfg.Send); err != nil { + return nil, fmt.Errorf("serve send_draft: %w", err) + } + } + + d := dispatcher{api: api} + if send != nil { + d.markExposed = func(ctx context.Context, action string) error { + return send.cfg.State.MarkExposed(ctx, send.cfg.Grant.Key, action, send.cfg.Now()) + } + } + gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{ ReadOnly: cfg.ReadOnly, Domains: cfg.Domains, - Handler: dispatcher{api: api}.handle, + Handler: d.handle, }) if err != nil { return nil, err } - return &Server{gw: gw}, nil + return &Server{gw: gw, sender: send}, nil +} + +// servesDomain reports whether a domain selection includes key; empty selects all. +func servesDomain(selected []string, key string) bool { + return len(selected) == 0 || slices.Contains(selected, key) +} + +// ServesSend reports whether send_draft is served. +func (s *Server) ServesSend() bool { + return s.sender != nil } // Domains returns the served domains. @@ -67,5 +97,9 @@ func (s *Server) Domains() []gateway.Domain { // BuildMCPServer constructs the SDK MCP server with one gateway tool per // served domain. func (s *Server) BuildMCPServer(logger *slog.Logger) *mcp.Server { - return s.gw.BuildMCPServer(&mcp.Implementation{Name: Name, Version: version.Version}, logger) + server := s.gw.BuildMCPServer(&mcp.Implementation{Name: Name, Version: version.Version}, logger) + if s.sender != nil { + server.AddTool(s.sender.tool(), s.sender.handle) + } + return server } From 13c80ffa4fed045dc2bd89cccda8a8e02fd35c02 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 15:53:10 -0700 Subject: [PATCH 02/16] Close the gaps review found in send_draft's safeguards - Record exposure whenever the sign-in can send, including from a read-only or narrowed hey mcp, since another process on the same sign-in may send. - Refuse sends and exposing reads once the stored sign-in no longer matches the one the process started on. - Measure the body against the cap as the prompt shows it, escapes included. - Refuse attachments that aren't plain blobs: no sgid or filename, embedded content, stray attributes or markup inside. - Show every address in full in the confirmation. - Keep a new address in the rolling window for 24 hours after its last send. - Report can_send with the same predicate hey mcp serves hey_send on. --- internal/cmd/mcp.go | 17 +++-- internal/cmd/mcp_auth.go | 14 +++- internal/cmd/mcp_auth_test.go | 14 ++++ internal/mcpserver/send.go | 40 +++++++++- internal/mcpserver/send_test.go | 127 +++++++++++++++++++++++++++++++ internal/mcpserver/sendbody.go | 120 ++++++++++++++++++++++------- internal/mcpserver/sendprompt.go | 4 +- internal/mcpserver/sendstate.go | 8 +- internal/mcpserver/server.go | 22 +++--- 9 files changed, 317 insertions(+), 49 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 286d45dda..5d99d36d7 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -115,13 +115,13 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { } // The tools follow the grant: without write, nothing that writes is served; without - // send (and read, which send_draft's review needs), no hey_send. + // send, no hey_send. readOnly := c.readOnly || !auth.HasScope(creds.Scope, "write") serverCfg := mcpserver.Config{ReadOnly: readOnly, Domains: c.domains} - // The sign-in's local key is the principal send safety counts against, so a - // credential without one serves no send. - canSend := auth.HasScope(creds.Scope, "read", "send") && creds.GrantKey != "" - if canSend && !readOnly { + // Send safety is configured whenever the sign-in can send, read-only or not: this + // process records exposure for any other hey mcp on the same sign-in that sends. + canSend := mcpCanSend(creds) + if canSend { state, stateErr := mcpserver.OpenSendState(config.StateDir()) if stateErr != nil { return stateErr @@ -136,6 +136,13 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { State: state, Grant: mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt}, Origin: strings.TrimRight(cfg.BaseURL, "/"), + CurrentGrant: func(context.Context) (string, error) { + stored, loadErr := mcpAuthMgr.StoredCredentials() + if loadErr != nil { + return "", loadErr + } + return stored.GrantKey, nil + }, } } diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go index 79786fb38..f0585ac93 100644 --- a/internal/cmd/mcp_auth.go +++ b/internal/cmd/mcp_auth.go @@ -64,6 +64,14 @@ func requireMCPCredentials() (*auth.Credentials, error) { return creds, nil } +// mcpCanSend is whether hey mcp serves send_draft on creds: the grant holds send, and +// write, without which hey mcp serves reads only (and read, which write implies). The +// sign-in's local key is the principal send safety counts against, so a credential +// without one can't send. +func mcpCanSend(creds *auth.Credentials) bool { + return auth.HasScope(creds.Scope, "write", "send") && creds.GrantKey != "" +} + // loginMCP runs `hey auth login --mcp`. func loginMCP(cmd *cobra.Command, noBrowser bool) error { if errMCPClientIDConfig != nil { @@ -86,10 +94,10 @@ func loginMCP(cmd *cobra.Command, noBrowser bool) error { if err != nil { return err } - canSend := auth.HasScope(creds.Scope, "read", "send") + canSend := mcpCanSend(creds) line := fmt.Sprintf("Signed hey mcp in (%s).", creds.Scope) if !canSend { - line += " Sending wasn't granted, so hey mcp saves drafts but can't send them; sign in again and leave sending checked to allow it." + line += " hey mcp can't send on this sign-in (it needs write and send); sign in again and leave both checked to allow it." } return writeMutationLine(cmd, line, "hey mcp signed in", map[string]any{"method": method, "scope": creds.Scope, "can_send": canSend}) @@ -140,7 +148,7 @@ func mcpCredentialStatus() map[string]any { } status["authenticated"] = true status["scope"] = creds.Scope - status["can_send"] = auth.HasScope(creds.Scope, "read", "send") + status["can_send"] = mcpCanSend(creds) if creds.ExpiresAt > 0 { expiry := time.Unix(creds.ExpiresAt, 0) status["expires_at"] = expiry.Format(time.RFC3339) diff --git a/internal/cmd/mcp_auth_test.go b/internal/cmd/mcp_auth_test.go index 61f9747a6..752d169c5 100644 --- a/internal/cmd/mcp_auth_test.go +++ b/internal/cmd/mcp_auth_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" ) // fakeLocalMCPOAuth serves the device flow and revocation for the local MCP client. @@ -133,3 +134,16 @@ func TestAuthLoginMCPTakesNoTokenOrCookie(t *testing.T) { t.Fatal("--mcp stored a hand-supplied token") } } + +// can_send is the same predicate hey mcp serves hey_send on: send alone, without write, +// leaves hey mcp read-only. +func TestMCPCanSendNeedsWriteAndSend(t *testing.T) { + for scope, want := range map[string]bool{"read write send": true, "write send": true, "read send": false, "read write": false} { + if got := mcpCanSend(&auth.Credentials{Scope: scope, GrantKey: "g"}); got != want { + t.Errorf("mcpCanSend(%q) = %v, want %v", scope, got, want) + } + } + if mcpCanSend(&auth.Credentials{Scope: "read write send"}) { + t.Error("a sign-in without a local key can send") + } +} diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index 9aa07ff24..f1890a6ee 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -69,6 +70,12 @@ type SendConfig struct { Origin string // Now is the clock; nil is time.Now. Now func() time.Time + // CurrentGrant answers the sign-in's key as stored now. When it no longer matches + // Grant.Key (hey mcp was signed in again or out since this process started, and the + // token the client authenticates with may be the new one), sends and exposing reads + // are refused until hey mcp restarts, so caps and exposure never count against a + // sign-in other than the one the requests ride. Nil skips the check. + CurrentGrant func(context.Context) (string, error) } const ( @@ -181,6 +188,9 @@ func (s *sender) handle(ctx context.Context, req *mcp.CallToolRequest) (*mcp.Cal if err != nil { return gateway.ErrorResult("%v", err), nil } + if err := s.checkGrant(ctx); err != nil { + return gateway.ErrorResult("not sent: %v", err), nil + } // A host that can't show the person a confirmation can't send (D3). There is no // "I confirm" parameter: a value the model supplies is what an injected @@ -271,6 +281,30 @@ func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState str return gateway.JSONResult(result) } +// checkGrant refuses when the stored sign-in isn't the one this process started on. +func (s *sender) checkGrant(ctx context.Context) error { + if s.cfg.CurrentGrant == nil { + return nil + } + current, err := s.cfg.CurrentGrant(ctx) + if err != nil { + return fmt.Errorf("hey mcp couldn't read its sign-in (%w)", err) + } + if current != s.cfg.Grant.Key { + return errors.New("hey mcp was signed in again, or signed out, since it started; restart it") + } + return nil +} + +// markExposed records an exposing read against this process's sign-in, refusing when +// the stored sign-in has changed under it. +func (s *sender) markExposed(ctx context.Context, action string) error { + if err := s.checkGrant(ctx); err != nil { + return err + } + return s.cfg.State.MarkExposed(ctx, s.cfg.Grant.Key, action, s.cfg.Now()) +} + // notSent is D3's answer: not an error, a draft and where to send it. func (s *sender) notSent(draftID int64, why string) *mcp.CallToolResult { url := s.editURL(draftID) @@ -488,6 +522,8 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re return refuse("not sent: the draft has no recipients; add them with update_message (entry.addressed)") case len(recipients) > maxRecipients: return refuse("not sent: hey mcp sends to at most %d recipients across To, Cc and Bcc, and this draft has %d. Send it from HEY: %s", maxRecipients, len(recipients), s.editURL(draftID)) + case snap.body.unverifiable != "": + return refuse("not sent: the draft's body has an attachment hey mcp can't verify (%s), so the confirmation couldn't show what it sends. Send it from HEY: %s", snap.body.unverifiable, s.editURL(draftID)) case snap.body.remote != "": return refuse("not sent: the draft's body has a reference a mail client loads by itself (%s), which can carry mailbox data to whoever hosts it. Send it from HEY: %s", snap.body.remote, s.editURL(draftID)) } @@ -509,7 +545,9 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re by, int(exposureWindow/time.Minute), newAddresses[0], s.editURL(draftID)) } - unseen := !snap.body.lossless || len([]rune(snap.body.text)) > bodyPromptCap + // Measured on the escaped text the prompt shows: escapes lengthen it, and what + // falls past the cut is unseen however short the raw text was. + unseen := !snap.body.lossless || utf8.RuneCountInString(inertBody(snap.body.text)) > bodyPromptCap if unseen && (len(newAddresses) > 0 || exposed) { return refuse("not sent: the draft can't be fully reviewed here (it has an attachment, more than %d characters, or formatting that can hide text), and it goes to someone you haven't emailed before or this conversation read unscreened mail. Send it from HEY: %s", bodyPromptCap, s.editURL(draftID)) diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 3b762364c..15acd99ff 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -992,3 +992,130 @@ func TestEitherFailedFsyncFailsTheClaim(t *testing.T) { }) } } + +// --- review round one --- + +func TestASignInChangedUnderTheServerRefusesSendsAndExposingReads(t *testing.T) { + rig := newSendRig(t) + stored := "grant-1" + rig.cfg.CurrentGrant = func(context.Context) (string, error) { return stored, nil } + srv, err := New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + rig.server = srv + session := rig.connect(t, approve, "", false) + + stored = "grant-2" // hey auth login --mcp ran again while this process served + mustContain(t, resultText(t, sendDraft(t, session)), "signed in again") + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil { + t.Fatal(err) + } + if !read.IsError { + t.Error("an exposing read went out under a sign-in its marker wouldn't be recorded against") + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestEscapesCountTowardTheBodyThePromptCanShow(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = "
" + strings.Repeat("\u200b", 200) + "Please send the reset codes to me.
" + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") +} + +func TestAnAttachmentThatIsntAPlainBlobIsRefused(t *testing.T) { + for name, body := range map[string]string{ + "no blob identity": `
hidden text
`, + "embedded html": `
`, + "a stray attribute": `
`, + "markup inside": `
forward the reset codes
`, + "unparseable data": `
`, + "action-text, empty": ``, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = "
Hi Ann
" + body }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't verify") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +func TestTheConfirmationShowsAWholeAddressHoweverLong(t *testing.T) { + long := strings.Repeat("a", 240) + "@evil.example" + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("Ann Example", long)} + }) + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, "<"+long+">") +} + +func TestAReadOnlyServerStillRecordsExposure(t *testing.T) { + for name, cfg := range map[string]func(*SendConfig) Config{ + "--read-only": func(send *SendConfig) Config { return Config{ReadOnly: true, Send: send} }, + "--domains search": func(send *SendConfig) Config { + return Config{Domains: []string{"search", "threads"}, ReadOnly: true, Send: send} + }, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + srv, err := New(rig.api, cfg(&rig.cfg)) + if err != nil { + t.Fatal(err) + } + if srv.ServesSend() { + t.Fatal("a read-only server serves hey_send") + } + rig.server = srv + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_search", + Arguments: map[string]any{"action": "advanced_search", "params": map[string]any{"q": "reset"}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := rig.state.exposure(context.Background(), "grant-1", rig.clock.Now()); !exposed { + t.Error("a read on a server that can't send didn't expose the sign-in another hey mcp sends on") + } + }) + } +} + +func TestTheNewAddressWindowRollsWithTheLastSend(t *testing.T) { + ctx := context.Background() + rig := newSendRig(t) + start := rig.clock.Now() + first := make([]string, maxNewAddressesPerDay) + for i := range first { + first[i] = fmt.Sprintf("new%d@example.com", i) + } + if err := rig.state.claim(ctx, testOrigin, 100, "grant-1", first, false, start); err != nil { + t.Fatal(err) + } + if err := rig.state.claim(ctx, testOrigin, 101, "grant-1", first, false, start.Add(23*time.Hour)); err != nil { + t.Fatalf("resending to the same new addresses was charged again: %v", err) + } + var refused *refusalError + if err := rig.state.claim(ctx, testOrigin, 102, "grant-1", []string{"eleventh@example.com"}, false, start.Add(24*time.Hour+time.Minute)); !errors.As(err, &refused) { + t.Errorf("an eleventh new address fit in a window that still held ten: %v", err) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index 04978c10a..93a84724c 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -34,6 +34,11 @@ type reviewedBody struct { attachments []attachment // remote names the first reference a mail client would fetch on open, or "". remote string + // unverifiable names the first attachment element that isn't a plain blob + // attachment (no sgid or filename, embedded HTML content, or markup Trix doesn't + // write around one), or "". Its contents can't be reduced to a blob's identity, so + // such a draft isn't sent. + unverifiable string // hash is the sha256 of the body with each attachment reduced to its identity and // the volatile attributes (signed URLs) dropped, so two fetches of an unchanged draft // hash alike and a changed attachment doesn't. @@ -90,11 +95,12 @@ func analyzeBody(content string) (reviewedBody, error) { sum := sha256.Sum256([]byte(w.canonical.String())) return reviewedBody{ - text: tidyText(w.text.String()), - lossless: w.lossless && len(w.attachments) == 0, - attachments: w.attachments, - remote: w.remote, - hash: hex.EncodeToString(sum[:]), + text: tidyText(w.text.String()), + lossless: w.lossless && len(w.attachments) == 0, + attachments: w.attachments, + remote: w.remote, + unverifiable: w.unverifiable, + hash: hex.EncodeToString(sum[:]), }, nil } @@ -103,6 +109,7 @@ type bodyWalker struct { lossless bool attachments []attachment remote string + unverifiable string } func (w *bodyWalker) walk(n *html.Node) { @@ -123,7 +130,11 @@ func (w *bodyWalker) walk(n *html.Node) { return } - if a, ok := attachmentOf(n); ok { + a, isAttachment, problem := attachmentOf(n) + if problem != "" && w.unverifiable == "" { + w.unverifiable = problem + } + if isAttachment && problem == "" { // The attachment's own markup (the preview image on a signed URL, the caption // Trix derives) is HEY's, not the sender's: reduce it to the blob's identity. w.attachments = append(w.attachments, a) @@ -190,31 +201,88 @@ func loadsByItself(attr html.Attribute) bool { } // attachmentOf reads a Trix attachment element: a figure carrying data-trix-attachment, -// or an action-text-attachment. -func attachmentOf(n *html.Node) (attachment, bool) { - if n.DataAtom == atom.Figure { - raw := attrValue(n, "data-trix-attachment") - if raw == "" { - return attachment{}, false +// or an action-text-attachment. It answers whether n is one, and a problem when it is +// one whose contents can't be reduced to a blob's identity: no sgid or filename (Trix +// carries an embedded email body as a filename-less text/html attachment whose markup +// is in its JSON), embedded content, an attribute Trix doesn't write on one, or +// anything inside but its preview image and caption. +func attachmentOf(n *html.Node) (attachment, bool, string) { + var a attachment + var allowed map[string]bool + var embedded bool + switch { + case n.DataAtom == atom.Figure && hasAttr(n, "data-trix-attachment"): + var data map[string]any + if json.Unmarshal([]byte(attrValue(n, "data-trix-attachment")), &data) != nil { + return a, true, "a figure whose attachment data doesn't parse" } - var data struct { - SGID string `json:"sgid"` - Filename string `json:"filename"` - Filesize any `json:"filesize"` - ContentType string `json:"contentType"` + var attributes map[string]any + _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) + _, embedded = data["content"] + a = attachment{SGID: asText(data["sgid"]), Filename: asText(data["filename"]), Size: sizeOf(data["filesize"]), ContentType: asText(data["contentType"]), Caption: asText(attributes["caption"])} + allowed = map[string]bool{"data-trix-attachment": true, "data-trix-attributes": true, "data-trix-content-type": true, "class": true} + case n.Data == "action-text-attachment": + size, _ := strconv.ParseInt(attrValue(n, "filesize"), 10, 64) + embedded = hasAttr(n, "content") + a = attachment{SGID: attrValue(n, "sgid"), Filename: attrValue(n, "filename"), Size: size, ContentType: attrValue(n, "content-type"), Caption: attrValue(n, "caption")} + allowed = map[string]bool{"sgid": true, "filename": true, "filesize": true, "content-type": true, "caption": true, "url": true, "presentation": true, "previewable": true, "width": true, "height": true} + default: + return a, false, "" + } + + name := a.Filename + if name == "" { + name = "an unnamed attachment" + } + switch { + case embedded: + return a, true, name + " carries embedded content" + case a.SGID == "" || a.Filename == "": + return a, true, name + " has no blob identity" + } + for _, attr := range n.Attr { + if !allowed[attr.Key] { + return a, true, name + " carries " + attr.Key } - _ = json.Unmarshal([]byte(raw), &data) - var attributes struct { - Caption string `json:"caption"` + } + if !plainAttachmentChildren(n) { + return a, true, name + " wraps markup of its own" + } + return a, true, "" +} + +// plainAttachmentChildren reports whether an attachment holds only what Trix renders +// in one: whitespace, a preview image, and a caption of plain text. +func plainAttachmentChildren(n *html.Node) bool { + for c := n.FirstChild; c != nil; c = c.NextSibling { + switch { + case c.Type == html.TextNode && strings.TrimSpace(c.Data) == "": + case c.Type == html.ElementNode && c.DataAtom == atom.Img && c.FirstChild == nil: + case c.Type == html.ElementNode && c.DataAtom == atom.Figcaption: + for t := c.FirstChild; t != nil; t = t.NextSibling { + if t.Type != html.TextNode { + return false + } + } + default: + return false } - _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) - return attachment{SGID: data.SGID, Filename: data.Filename, Size: sizeOf(data.Filesize), ContentType: data.ContentType, Caption: attributes.Caption}, true } - if n.Data == "action-text-attachment" { - size, _ := strconv.ParseInt(attrValue(n, "filesize"), 10, 64) - return attachment{SGID: attrValue(n, "sgid"), Filename: attrValue(n, "filename"), Size: size, ContentType: attrValue(n, "content-type"), Caption: attrValue(n, "caption")}, true + return true +} + +func hasAttr(n *html.Node, key string) bool { + for _, attr := range n.Attr { + if attr.Key == key { + return true + } } - return attachment{}, false + return false +} + +func asText(v any) string { + s, _ := v.(string) + return s } func sizeOf(v any) int64 { diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go index e9d1cfbfa..26e6c51ed 100644 --- a/internal/mcpserver/sendprompt.go +++ b/internal/mcpserver/sendprompt.go @@ -53,7 +53,9 @@ func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { // recipientLine shows a recipient as "Name"
, so the address the mail goes to is // always visible beside whatever name the draft carries. func recipientLine(c generated.Contact, fresh map[string]bool) string { - address := "<" + inertLine(c.EmailAddress) + ">" + // The address is escaped but never truncated: the person has to see all of where + // the mail goes. + address := "<" + escapeInvisible(c.EmailAddress, false) + ">" line := address if name := strings.TrimSpace(c.Name); name != "" && !strings.EqualFold(name, c.EmailAddress) { line = `"` + strings.ReplaceAll(inertLine(name), `"`, `\"`) + `" ` + address diff --git a/internal/mcpserver/sendstate.go b/internal/mcpserver/sendstate.go index be43ca877..ec1665149 100644 --- a/internal/mcpserver/sendstate.go +++ b/internal/mcpserver/sendstate.go @@ -76,8 +76,8 @@ type sendStateData struct { type grantState struct { // Sends are when each confirmed send was claimed, Unix seconds. Sends []int64 `json:"sends,omitempty"` - // NewAddresses are the new addresses charged in the window, each with when it was - // first charged: a send is charged only for new addresses not already here (D4). + // NewAddresses are the new addresses sent to in the window, each with when it was + // last sent to: a send is charged only for new addresses not already here (D4). NewAddresses map[string]int64 `json:"new_addresses,omitempty"` // ExposedUntil is when the exposure window ends, Unix seconds (D6). ExposedUntil int64 `json:"exposed_until,omitempty"` @@ -378,7 +378,9 @@ func (s *SendState) claim(ctx context.Context, origin string, draftID int64, gra d.Claims[key] = now.Unix() g.Sends = append(g.Sends, now.Unix()) - for _, address := range charge { + // Each new address counts once, and stays in the window for 24 hours after the + // last send to it, so the window holds every new address sent to within it. + for _, address := range newAddresses { g.NewAddresses[address] = now.Unix() } return nil diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 3f8be20ae..f4d97ed58 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -1,7 +1,6 @@ package mcpserver import ( - "context" "fmt" "log/slog" "slices" @@ -26,8 +25,10 @@ type Config struct { // Domains narrows the served domains by key ("boxes", "search", ...). // Empty means all. Unknown keys are a startup error — fail closed. Domains []string - // Send serves send_draft in its own hey_send tool (send.go), when the threads - // domain is served and the server isn't read-only. Nil serves no way to send. + // Send is set when the sign-in can send. It serves send_draft in its own hey_send + // tool (send.go) when the threads domain is served and the server isn't read-only, + // and either way records which reads expose the sign-in (exposure.go). Nil serves no + // way to send and records nothing. Send *SendConfig } @@ -53,18 +54,19 @@ func New(api API, cfg Config) (*Server, error) { return nil, fmt.Errorf("hold sending operations to drafts: %w", err) } + // Exposure is recorded whenever the sign-in can send, whether or not this process + // serves hey_send: another hey mcp on the same sign-in may, and it reads the same + // state file. var send *sender - if cfg.Send != nil && !cfg.ReadOnly && servesDomain(cfg.Domains, "threads") { + d := dispatcher{api: api} + if cfg.Send != nil { if send, err = newSender(*cfg.Send); err != nil { return nil, fmt.Errorf("serve send_draft: %w", err) } + d.markExposed = send.markExposed } - - d := dispatcher{api: api} - if send != nil { - d.markExposed = func(ctx context.Context, action string) error { - return send.cfg.State.MarkExposed(ctx, send.cfg.Grant.Key, action, send.cfg.Now()) - } + if cfg.ReadOnly || !servesDomain(cfg.Domains, "threads") { + send = nil } gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{ From 75e2c517262da015f871c1da08e62c7463acc877 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 16:10:49 -0700 Subject: [PATCH 03/16] Close round two's gaps in send_draft and the device sign-in - Report a delivery HEY kept as a draft as not sent (the redirect to the edit page that mail.ConfirmDelivered reads for hey draft send). - Count a recipient known by when the mail went out, not when the draft was created. - Treat an escaped or functional style as a loading reference. - Accept an attachment's preview image only from HEY's own host, with no attributes but its size and alt text. - Refuse a send when a refresh narrowed the grant below sending. - Sanitize the device link and code before they reach the terminal, and open only a web link. --- internal/auth/device.go | 15 ++++++- internal/auth/mcp_test.go | 38 ++++++++++++++++ internal/cmd/mcp.go | 5 +++ internal/mcpserver/send.go | 32 +++++++++++--- internal/mcpserver/send_test.go | 78 +++++++++++++++++++++++++++++++-- internal/mcpserver/sendbody.go | 46 ++++++++++++++----- internal/mcpserver/server.go | 2 +- 7 files changed, 194 insertions(+), 22 deletions(-) diff --git a/internal/auth/device.go b/internal/auth/device.go index 17982c9e4..bf59d0cf8 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/basecamp/hey-cli/internal/terminal" "github.com/basecamp/hey-cli/internal/version" ) @@ -62,9 +63,13 @@ func (m *Manager) LoginDevice(ctx context.Context, opts LoginOptions) error { if link == "" { link = authorization.VerificationURI } - opts.log(fmt.Sprintf("\nTo sign in, open %s\nand check that HEY shows this code: %s\n", link, authorization.UserCode)) + // Both come from HEY's answer, so what reaches the terminal is sanitized, and only + // a web link is handed to the browser. + opts.log(fmt.Sprintf("\nTo sign in, open %s\nand check that HEY shows this code: %s\n", terminal.SanitizeLine(link), terminal.SanitizeLine(authorization.UserCode))) if !opts.NoBrowser { - if browserErr := openBrowser(link); browserErr != nil { + if !webLink(link) { + opts.log("HEY's sign-in link isn't a web address, so it wasn't opened.\n") + } else if browserErr := openBrowser(link); browserErr != nil { opts.log("Couldn't open the browser automatically; open the link above.\n") } } @@ -110,6 +115,12 @@ func (m *Manager) LoginDevice(ctx context.Context, opts LoginOptions) error { } } +// webLink reports whether link is an absolute http or https URL. +func webLink(link string) bool { + u, err := url.Parse(link) + return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" +} + // requestDeviceAuthorization asks HEY for a device code (POST /oauth/device_authorizations). func (m *Manager) requestDeviceAuthorization(ctx context.Context) (*deviceAuthorization, error) { form := url.Values{"client_id": {m.clientID}} diff --git a/internal/auth/mcp_test.go b/internal/auth/mcp_test.go index 610f1c1ba..196e8d423 100644 --- a/internal/auth/mcp_test.go +++ b/internal/auth/mcp_test.go @@ -33,6 +33,8 @@ type fakeHEYOAuth struct { pollErrors []string // grantedScope is the token response's scope; "-" leaves it out. grantedScope string + // deviceBody replaces the device authorization response when set. + deviceBody string deviceRequests url.Values polls []url.Values @@ -61,6 +63,10 @@ func (f *fakeHEYOAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"error":%q}`, f.deviceError) return } + if f.deviceBody != "" { + fmt.Fprint(w, f.deviceBody) + return + } fmt.Fprint(w, `{"device_code":"secret-device-code","user_code":"WDJB-MJHT","verification_uri":"https://hey.example/device","verification_uri_complete":"https://hey.example/device?user_code=WDJB-MJHT","expires_in":600,"interval":5}`) case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == deviceGrantType: f.polls = append(f.polls, form) @@ -439,3 +445,35 @@ func TestHasScopeReadsTheExpandedSet(t *testing.T) { } } } + +// The link and code come from HEY's answer: what reaches the terminal is sanitized, and +// the device code, a secret, never does. +func TestDeviceLoginSanitizesWhatItShows(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceBody = `{"device_code":"secret-device-code","user_code":"WDJB\u001b]52;c;cGF5bG9hZA==\u0007-MJHT","verification_uri":"https://hey.example/device\u001b[2J\nFake: approved","expires_in":600,"interval":5}` + var shown strings.Builder + opts := LoginOptions{NoBrowser: true, Logger: func(msg string) { shown.WriteString(msg) }} + if _, err := rig.mcp.LoginMCP(context.Background(), opts); err != nil { + t.Fatal(err) + } + out := shown.String() + if strings.ContainsAny(out, "\x1b\x07") || strings.Contains(out, "\nFake: approved") { + t.Errorf("control sequences reached the terminal: %q", out) + } + if strings.Contains(out, "secret-device-code") { + t.Error("the device code was shown") + } +} + +func TestOnlyAWebLinkIsOpened(t *testing.T) { + for link, want := range map[string]bool{ + "https://app.hey.com/device?user_code=WDJB-MJHT": true, + "file:///etc/passwd": false, + "javascript:alert(1)": false, + "/device": false, + } { + if got := webLink(link); got != want { + t.Errorf("webLink(%q) = %v, want %v", link, got, want) + } + } +} diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 5d99d36d7..edc0a1511 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -141,6 +141,11 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { if loadErr != nil { return "", loadErr } + // A refresh that narrowed the grant below sending is a different + // sign-in as far as sending goes: refuse before claiming. + if !mcpCanSend(stored) { + return "", nil + } return stored.GrantKey, nil }, } diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index f1890a6ee..78a8a71ca 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "strconv" "strings" @@ -268,6 +269,16 @@ func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState str return gateway.ErrorResult("the send may have gone out: %v. Check Sent in HEY before doing anything else; hey mcp won't send this draft again", err), nil } + // HEY refuses a delivery it won't make (usually the account's sending limit) by + // keeping the draft and redirecting to its edit page, which reads as a message with + // an id and no thread. Only an edit page served says it's still a draft (as + // mail.ConfirmDelivered reads it for hey draft send). + if sent != nil && sent.Id != 0 && sent.TopicId == 0 { + if edit, editErr := s.cfg.Drafts.GetEdit(ctx, sent.Id); editErr == nil && edit != nil { + return gateway.ErrorResult("not sent: HEY kept the draft instead of sending it, usually because the account reached its sending limit. hey mcp won't send this draft again for 24 hours; send it from HEY when the limit allows: %s", s.editURL(draftID)), nil + } + } + result := map[string]any{"sent": true, "draft_id": draftID, "note": "Sent through HEY's undo window. The person approved it in this app."} if sent != nil { if sent.Id != 0 { @@ -317,6 +328,15 @@ func (s *sender) notSent(draftID int64, why string) *mcp.CallToolResult { return result } +// heyHost is the host of the HEY this sender runs against. +func (s *sender) heyHost() string { + u, err := url.Parse(s.cfg.Origin) + if err != nil { + return "" + } + return u.Host +} + func (s *sender) editURL(draftID int64) string { return fmt.Sprintf("%s/messages/%d/edit", strings.TrimRight(s.cfg.Origin, "/"), draftID) } @@ -425,8 +445,8 @@ func normalizedAddress(address string) string { return strings.ToLower(strings.TrimSpace(address)) } -func snapshotOf(edit *generated.MessageEditState) (*draftSnapshot, error) { - body, err := analyzeBody(edit.Content) +func snapshotOf(edit *generated.MessageEditState, heyHost string) (*draftSnapshot, error) { + body, err := analyzeBody(edit.Content, heyHost) if err != nil { return nil, err } @@ -509,7 +529,7 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re if edit == nil || edit.Id != draftID { return refuse("not sent: %d isn't a draft HEY can send", draftID) } - snap, err := snapshotOf(edit) + snap, err := snapshotOf(edit, s.heyHost()) if err != nil { return refuse("not sent: %v", err) } @@ -603,7 +623,7 @@ func (s *sender) newAddresses(ctx context.Context, snap *draftSnapshot) []string } // knownAddresses looks each address up in HEY's search for a message to it from one of -// this identity's addresses, created before the sign-in. The search only narrows: each +// this identity's addresses, delivered before the sign-in. The search only narrows: each // match is checked here against the entry's own creator, time and recipients, so a // search that matches loosely can't make an address known. func (s *sender) knownAddresses(ctx context.Context, addresses []string) map[string]bool { @@ -638,7 +658,9 @@ func (s *sender) knownAddresses(ctx context.Context, addresses []string) map[str func sentBefore(result *generated.AdvancedSearchResult, address string, mine map[string]bool, before time.Time) bool { for _, match := range result.Matches { for _, entry := range match.Entries { - if !mine[normalizedAddress(entry.Creator.EmailAddress)] || entry.CreatedAt.IsZero() || !entry.CreatedAt.Before(before) { + // ActiveAt is when the entry went out; CreatedAt would count a draft made + // before the sign-in and sent after it. No delivery time, no proof. + if !mine[normalizedAddress(entry.Creator.EmailAddress)] || entry.ActiveAt.IsZero() || !entry.ActiveAt.Before(before) { continue } for _, r := range slices.Concat(entry.Addressed.Directly, entry.Addressed.Copied, entry.Addressed.Blindcopied) { diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 15acd99ff..a78537a5c 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -38,6 +38,7 @@ type fakeDrafts struct { editErr error sends []hey.DraftContent sendErr error + sentAs *generated.SentMessage sentTo map[string]time.Time searches int } @@ -92,6 +93,9 @@ func (f *fakeDrafts) SendDraft(_ context.Context, _ int64, draft hey.DraftConten if f.sendErr != nil { return nil, f.sendErr } + if f.sentAs != nil { + return f.sentAs, nil + } return &generated.SentMessage{Id: 5, TopicId: 900}, nil } @@ -111,7 +115,8 @@ func (f *fakeDrafts) Search(_ context.Context, params hey.SearchParams) (*genera return &generated.AdvancedSearchResult{}, nil } return &generated.AdvancedSearchResult{Matches: []generated.SearchMatch{{Entries: []generated.Entry{{ - CreatedAt: at, + CreatedAt: at.Add(-time.Hour), + ActiveAt: at, Creator: generated.Contact{EmailAddress: "jeremy@hey.example"}, Addressed: generated.Addressed{Directly: []generated.Contact{{EmailAddress: params.To}}}, }}}}}, nil @@ -392,12 +397,12 @@ func TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange(t *testing.T) { figure := func(sgid, url string) string { return fmt.Sprintf(`
Plan attached.
plan.pdf
`, sgid, url, url) } - first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1"), "app.hey.example") if err != nil { t.Fatal(err) } - refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2")) - replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2"), "app.hey.example") + replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1"), "app.hey.example") if first.hash != refetched.hash { t.Error("an unchanged attachment hashed differently on a second fetch (signed URLs must not count)") @@ -1119,3 +1124,68 @@ func TestTheNewAddressWindowRollsWithTheLastSend(t *testing.T) { t.Errorf("an eleventh new address fit in a window that still held ten: %v", err) } } + +// --- review round two --- + +func TestADeliveryHEYKeptAsADraftIsntReportedSent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentAs = &generated.SentMessage{Id: 5} // the redirect to the edit page + session := rig.connect(t, approve, "", false) + result := sendDraft(t, session) + if !result.IsError || strings.Contains(resultText(t, result), `"sent": true`) { + t.Fatalf("a draft HEY kept was reported sent: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), "kept the draft") +} + +func TestADraftMadeBeforeTheSignInButSentAfterDoesntMakeARecipientKnown(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentTo["ann@example.com"] = grantedAt.Add(time.Hour) // created an hour before, delivered an hour after + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, " (new: never emailed before)") +} + +func TestAnEscapedOrFunctionalStyleCountsAsLoading(t *testing.T) { + for name, style := range map[string]string{ + "css escape": `background:u\72l(https://tracker.example/p.gif)`, + "function call": `background:image(https://tracker.example/p.gif)`, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = `
Hi Ann
` }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") + }) + } +} + +func TestAnAttachmentPreviewMustComeFromHEY(t *testing.T) { + figure := func(img string) string { + return `
Plan attached.
` + img + `
` + } + for name, tc := range map[string]struct { + img string + sent bool + }{ + "HEY's own preview": {``, true}, + "a relative preview": {``, true}, + "a tracker": {``, false}, + "a protocol-relative src": {``, false}, + "a srcset": {``, false}, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = figure(tc.img) }) + session := rig.connect(t, approve, "", false) + result := sendDraft(t, session) + if sent := !result.IsError; sent != tc.sent { + t.Errorf("sent = %v, want %v: %s", sent, tc.sent, resultText(t, result)) + } + }) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index 93a84724c..c7c799ba8 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/url" "slices" "strconv" "strings" @@ -81,14 +82,15 @@ var loadingTags = map[atom.Atom]bool{ // loadingAttributes name a URL a client loads by itself. var loadingAttributes = []string{"src", "srcset", "background", "poster", "lowsrc", "dynsrc", "data", "xlink:href", "action", "formaction"} -// analyzeBody reads a draft's raw content. -func analyzeBody(content string) (reviewedBody, error) { +// analyzeBody reads a draft's raw content. heyHost is the HEY host the draft lives +// on: an attachment's preview image may load only from there. +func analyzeBody(content, heyHost string) (reviewedBody, error) { nodes, err := html.ParseFragment(strings.NewReader(content), &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}) if err != nil { return reviewedBody{}, fmt.Errorf("read the draft's body: %w", err) } - w := &bodyWalker{lossless: true} + w := &bodyWalker{lossless: true, heyHost: heyHost} for _, n := range nodes { w.walk(n) } @@ -110,6 +112,7 @@ type bodyWalker struct { attachments []attachment remote string unverifiable string + heyHost string } func (w *bodyWalker) walk(n *html.Node) { @@ -130,7 +133,7 @@ func (w *bodyWalker) walk(n *html.Node) { return } - a, isAttachment, problem := attachmentOf(n) + a, isAttachment, problem := attachmentOf(n, w.heyHost) if problem != "" && w.unverifiable == "" { w.unverifiable = problem } @@ -194,8 +197,10 @@ func loadsByItself(attr html.Attribute) bool { return true } if key == "style" { + // CSS escapes spell url( without its letters (u\72l(...)), so a style with an + // escape, or any function call at all, counts as loading. Trix writes no style. value := strings.ToLower(attr.Val) - return strings.Contains(value, "url(") || strings.Contains(value, "image-set(") || strings.Contains(value, "@import") + return strings.ContainsAny(value, "\\(") || strings.Contains(value, "@import") } return false } @@ -206,7 +211,7 @@ func loadsByItself(attr html.Attribute) bool { // carries an embedded email body as a filename-less text/html attachment whose markup // is in its JSON), embedded content, an attribute Trix doesn't write on one, or // anything inside but its preview image and caption. -func attachmentOf(n *html.Node) (attachment, bool, string) { +func attachmentOf(n *html.Node, heyHost string) (attachment, bool, string) { var a attachment var allowed map[string]bool var embedded bool @@ -245,19 +250,19 @@ func attachmentOf(n *html.Node) (attachment, bool, string) { return a, true, name + " carries " + attr.Key } } - if !plainAttachmentChildren(n) { + if !plainAttachmentChildren(n, heyHost) { return a, true, name + " wraps markup of its own" } return a, true, "" } // plainAttachmentChildren reports whether an attachment holds only what Trix renders -// in one: whitespace, a preview image, and a caption of plain text. -func plainAttachmentChildren(n *html.Node) bool { +// in one: whitespace, a preview image served by HEY itself, and a caption of plain text. +func plainAttachmentChildren(n *html.Node, heyHost string) bool { for c := n.FirstChild; c != nil; c = c.NextSibling { switch { case c.Type == html.TextNode && strings.TrimSpace(c.Data) == "": - case c.Type == html.ElementNode && c.DataAtom == atom.Img && c.FirstChild == nil: + case c.Type == html.ElementNode && c.DataAtom == atom.Img && c.FirstChild == nil && heyPreview(c, heyHost): case c.Type == html.ElementNode && c.DataAtom == atom.Figcaption: for t := c.FirstChild; t != nil; t = t.NextSibling { if t.Type != html.TextNode { @@ -271,6 +276,27 @@ func plainAttachmentChildren(n *html.Node) bool { return true } +// heyPreview reports whether img is a preview HEY serves: a src on HEY's own host (or +// relative to it) and nothing but its size and alt text besides. +func heyPreview(img *html.Node, heyHost string) bool { + for _, attr := range img.Attr { + switch attr.Key { + case "width", "height", "alt": + case "src": + u, err := url.Parse(attr.Val) + if err != nil || (u.Host != "" && !strings.EqualFold(u.Host, heyHost)) || (u.Host == "" && !strings.HasPrefix(attr.Val, "/")) || strings.HasPrefix(attr.Val, "//") { + return false + } + if u.Scheme != "" && u.Scheme != "https" && u.Scheme != "http" { + return false + } + default: + return false + } + } + return true +} + func hasAttr(n *html.Node, key string) bool { for _, attr := range n.Attr { if attr.Key == key { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index f4d97ed58..94adaf7f6 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -63,7 +63,7 @@ func New(api API, cfg Config) (*Server, error) { if send, err = newSender(*cfg.Send); err != nil { return nil, fmt.Errorf("serve send_draft: %w", err) } - d.markExposed = send.markExposed + d = dispatcher{api: api, markExposed: send.markExposed} } if cfg.ReadOnly || !servesDomain(cfg.Domains, "threads") { send = nil From 47dc338afdd7a58b0d0625b4b66e5d7468fd9ab8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 16:13:21 -0700 Subject: [PATCH 04/16] Date the test's earlier mail so creation and delivery fall on either side of the sign-in --- internal/mcpserver/send_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index a78537a5c..48c6cb807 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -115,7 +115,7 @@ func (f *fakeDrafts) Search(_ context.Context, params hey.SearchParams) (*genera return &generated.AdvancedSearchResult{}, nil } return &generated.AdvancedSearchResult{Matches: []generated.SearchMatch{{Entries: []generated.Entry{{ - CreatedAt: at.Add(-time.Hour), + CreatedAt: at.Add(-2 * time.Hour), ActiveAt: at, Creator: generated.Contact{EmailAddress: "jeremy@hey.example"}, Addressed: generated.Addressed{Directly: []generated.Contact{{EmailAddress: params.To}}}, From 495d64110525d4dca2f3755e92a11643f6e69780 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 16:29:12 -0700 Subject: [PATCH 05/16] Deliver an attachment as its identity alone, and pin hey mcp to its sign-in Three rounds of review each found a new way markup inside or on an attachment's wrapper escaped the checks: preview image attributes, caption attributes, caption text, a backslash-relative URL. Each fix widened a validator over markup HEY regenerates anyway. Instead, send_draft now rebuilds each attachment from its sgid, filename, size, type and caption, with nothing inside, and sends and hashes exactly that body. The wrapper is never sent, so there is nothing in it to validate, show or bind. - Parse with scripting off, and refuse noscript, template and the raw-text fallbacks as loading markup. - Pin hey mcp's auth manager to the sign-in it starts on, so a later `hey auth login --mcp` is refused rather than adopted, whatever the first sign-in could do. - Refuse two mcp_client_ids for one origin. - Skip the directory-fsync case on Windows, where syncDir doesn't fsync. --- internal/auth/auth.go | 12 ++ internal/auth/mcp.go | 18 +++ internal/auth/mcp_test.go | 26 ++++ internal/cmd/mcp.go | 4 + internal/cmd/mcp_auth.go | 4 + internal/config/mcp.go | 3 + internal/config/mcp_test.go | 17 +++ internal/mcpserver/send.go | 39 +++--- internal/mcpserver/send_test.go | 68 ++++++---- internal/mcpserver/sendbody.go | 210 +++++++++++++++---------------- internal/mcpserver/sendprompt.go | 6 +- 11 files changed, 245 insertions(+), 162 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 681546862..4e7fc9c56 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,6 +43,11 @@ type Manager struct { honorEnvToken bool // wait sleeps between device-flow polls; a seam for tests. wait func(context.Context, time.Duration) error + // pinnedGrant, when set, is the only sign-in this manager authenticates with: a + // credential stored under another grant key (a later `hey auth login --mcp`) is + // refused rather than adopted, so a running hey mcp never moves onto a sign-in it + // didn't start with. Guarded by mu. + pinnedGrant string store *Store httpClient *http.Client @@ -422,6 +427,9 @@ func (m *Manager) loadCredentialsLocked() (*Credentials, error) { func (m *Manager) reloadCredentialsLocked() (*Credentials, error) { creds, err := m.store.Load(m.storeKey) + if err == nil { + err = m.checkPinnedGrant(creds) + } if err != nil { m.cachedCredentials = nil return nil, err @@ -463,6 +471,10 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { if loadErr != nil { return credentialLoadError(loadErr) } + if pinErr := m.checkPinnedGrant(stored); pinErr != nil { + m.cachedCredentials = nil + return pinErr + } m.cachedCredentials = cloneCredentials(stored) changed := stored.AccessToken != creds.AccessToken || stored.SessionCookie != creds.SessionCookie if changed && (stored.AccessToken != "" || stored.SessionCookie != "") { diff --git a/internal/auth/mcp.go b/internal/auth/mcp.go index 6a988ca09..cccdc14e9 100644 --- a/internal/auth/mcp.go +++ b/internal/auth/mcp.go @@ -71,6 +71,24 @@ func (m *Manager) LoginMCP(ctx context.Context, opts LoginOptions) (string, erro return "oauth", m.Login(ctx, opts) } +// PinGrant makes grantKey the only sign-in this manager authenticates with from now on +// (pinnedGrant). hey mcp pins the sign-in it starts on. +func (m *Manager) PinGrant(grantKey string) { + m.mu.Lock() + defer m.mu.Unlock() + m.pinnedGrant = grantKey +} + +// ErrGrantChanged is a stored credential under a different sign-in than the pinned one. +var ErrGrantChanged = errors.New("hey mcp was signed in again, or signed out, since it started; restart it") + +func (m *Manager) checkPinnedGrant(creds *Credentials) error { + if m.pinnedGrant != "" && creds.GrantKey != m.pinnedGrant { + return errNoCredential(ErrGrantChanged.Error(), ErrGrantChanged) + } + return nil +} + // StoredCredentials reads this manager's stored credential. func (m *Manager) StoredCredentials() (*Credentials, error) { return m.store.Load(m.storeKey) diff --git a/internal/auth/mcp_test.go b/internal/auth/mcp_test.go index 196e8d423..cb5ef0099 100644 --- a/internal/auth/mcp_test.go +++ b/internal/auth/mcp_test.go @@ -477,3 +477,29 @@ func TestOnlyAWebLinkIsOpened(t *testing.T) { } } } + +// A pinned manager authenticates only as the sign-in it started on: a later +// `hey auth login --mcp` isn't adopted, by a reload or by a refresh. +func TestAPinnedManagerRefusesAnotherSignIn(t *testing.T) { + rig := newMCPRig(t) + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + first, _ := rig.mcp.StoredCredentials() + rig.mcp.PinGrant(first.GrantKey) + + other := NewMCPManager(rig.server.URL, rig.server.Client(), t.TempDir(), testMCPClientID) + other.store = rig.mcp.store + other.wait = rig.mcp.wait + if _, err := other.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/boxes.json", nil) + if err := rig.mcp.AuthenticateRequestFromStore(context.Background(), req); !errors.Is(err, ErrGrantChanged) { + t.Errorf("a reload adopted another sign-in: %v (Authorization set: %v)", err, req.Header.Get("Authorization") != "") + } + if err := rig.mcp.Refresh(context.Background()); !errors.Is(err, ErrGrantChanged) { + t.Errorf("a refresh adopted another sign-in: %v", err) + } +} diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index edc0a1511..586248552 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -103,6 +103,10 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } + // Whatever it may do, this process authenticates only as the sign-in it starts on: + // one that started without send must not move onto a later send-capable sign-in + // whose exposure it doesn't record. + mcpAuthMgr.PinGrant(creds.GrantKey) ctx := cmd.Context() reads, err := clientForAccountSelection(ctx, newMCPSDKClient(), cfg.AccountID) diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go index f0585ac93..0a265a4ee 100644 --- a/internal/cmd/mcp_auth.go +++ b/internal/cmd/mcp_auth.go @@ -61,6 +61,10 @@ func requireMCPCredentials() (*auth.Credentials, error) { if creds.AccessToken == "" && creds.RefreshToken == "" { return nil, errMCPNotSignedIn() } + if creds.GrantKey == "" { + // Every --mcp sign-in mints one; without it the process couldn't be pinned. + return nil, errMCPNotSignedIn() + } return creds, nil } diff --git a/internal/config/mcp.go b/internal/config/mcp.go index ee23c736e..8038ee58a 100644 --- a/internal/config/mcp.go +++ b/internal/config/mcp.go @@ -77,6 +77,9 @@ func globalMCPClientIDs() (map[string]string, error) { if originErr != nil { return nil, fmt.Errorf("could not parse mcp_client_ids origin %s in %s: %w", origin, path, originErr) } + if existing, duplicate := ids[normalized]; duplicate && existing != id { + return nil, fmt.Errorf("conflicting mcp_client_ids for %s in %s", normalized, path) + } ids[normalized] = id } return ids, nil diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go index d7f8b857a..947d352c7 100644 --- a/internal/config/mcp_test.go +++ b/internal/config/mcp_test.go @@ -41,3 +41,20 @@ func TestMCPClientIDResolution(t *testing.T) { t.Error("a malformed HEY_MCP_CLIENT_ID was accepted") } } + +func TestConflictingSpellingsOfOneOriginAreRefused(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HEY_MCP_CLIENT_ID", "") + dir := filepath.Join(home, configDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + global := `{"mcp_client_ids":{"https://app.hey.com":"FirstLocalMcp00000000001","https://APP.HEY.COM:443":"OtherLocalMcp00000000001"}}` + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(global), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := MCPClientID("https://app.hey.com"); err == nil { + t.Error("two ids for one origin resolved to one of them") + } +} diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index 78a8a71ca..544a68031 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "slices" "strconv" "strings" @@ -328,15 +327,6 @@ func (s *sender) notSent(draftID int64, why string) *mcp.CallToolResult { return result } -// heyHost is the host of the HEY this sender runs against. -func (s *sender) heyHost() string { - u, err := url.Parse(s.cfg.Origin) - if err != nil { - return "" - } - return u.Host -} - func (s *sender) editURL(draftID int64) string { return fmt.Sprintf("%s/messages/%d/edit", strings.TrimRight(s.cfg.Origin, "/"), draftID) } @@ -408,7 +398,6 @@ type draftSnapshot struct { actingSenderID int64 to, cc, bcc []generated.Contact subject string - rawContent string body reviewedBody scheduled time.Time fingerprint string @@ -417,11 +406,12 @@ type draftSnapshot struct { // content is the delivery request: every field from the verified snapshot, so an edit // landing after the final fetch is overwritten by what was approved (D2). HEY replaces // the whole recipient set when addressed is present (haystack Entry::Addressed#address), -// so a Bcc added concurrently is dropped rather than delivered. +// so a Bcc added concurrently is dropped rather than delivered. The body is the one the +// fingerprint hashed, attachments rebuilt from their identity (sendbody.go). func (d *draftSnapshot) content() hey.DraftContent { return hey.DraftContent{ Subject: d.subject, - Content: d.rawContent, + Content: d.body.delivered, To: emails(d.to), CC: emails(d.cc), BCC: emails(d.bcc), @@ -445,21 +435,20 @@ func normalizedAddress(address string) string { return strings.ToLower(strings.TrimSpace(address)) } -func snapshotOf(edit *generated.MessageEditState, heyHost string) (*draftSnapshot, error) { - body, err := analyzeBody(edit.Content, heyHost) +func snapshotOf(edit *generated.MessageEditState) (*draftSnapshot, error) { + body, err := analyzeBody(edit.Content) if err != nil { return nil, err } snap := &draftSnapshot{ - id: edit.Id, - from: edit.Sender, - to: edit.Addressed.Directly, - cc: edit.Addressed.Copied, - bcc: edit.Addressed.Blindcopied, - subject: edit.Subject, - rawContent: edit.Content, - body: body, - scheduled: edit.ScheduledDeliveryAt, + id: edit.Id, + from: edit.Sender, + to: edit.Addressed.Directly, + cc: edit.Addressed.Copied, + bcc: edit.Addressed.Blindcopied, + subject: edit.Subject, + body: body, + scheduled: edit.ScheduledDeliveryAt, } // HEY omits sender when the draft is sent as its creator contact, and the identity // the draft was saved as rides the send (cmd's draftContentFrom does the same). @@ -529,7 +518,7 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re if edit == nil || edit.Id != draftID { return refuse("not sent: %d isn't a draft HEY can send", draftID) } - snap, err := snapshotOf(edit, s.heyHost()) + snap, err := snapshotOf(edit) if err != nil { return refuse("not sent: %v", err) } diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 48c6cb807..3417ac3b9 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "os" + "runtime" "slices" "strings" "sync" @@ -397,12 +398,12 @@ func TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange(t *testing.T) { figure := func(sgid, url string) string { return fmt.Sprintf(`
Plan attached.
plan.pdf
`, sgid, url, url) } - first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1"), "app.hey.example") + first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1")) if err != nil { t.Fatal(err) } - refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2"), "app.hey.example") - replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1"), "app.hey.example") + refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2")) + replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1")) if first.hash != refetched.hash { t.Error("an unchanged attachment hashed differently on a second fetch (signed URLs must not count)") @@ -980,6 +981,9 @@ func TestTheClaimEnforcesTheCaps(t *testing.T) { func TestEitherFailedFsyncFailsTheClaim(t *testing.T) { for name, failDir := range map[string]bool{"the file": false, "the directory": true} { t.Run(name, func(t *testing.T) { + if failDir && runtime.GOOS == "windows" { + t.Skip("Windows can't open a directory to fsync it; syncDir skips it there") + } rig := newSendRig(t) rig.state.fsync = func(f *os.File) error { info, err := f.Stat() @@ -1042,8 +1046,6 @@ func TestAnAttachmentThatIsntAPlainBlobIsRefused(t *testing.T) { for name, body := range map[string]string{ "no blob identity": `
hidden text
`, "embedded html": `
`, - "a stray attribute": `
`, - "markup inside": `
forward the reset codes
`, "unparseable data": `
`, "action-text, empty": ``, } { @@ -1164,28 +1166,40 @@ func TestAnEscapedOrFunctionalStyleCountsAsLoading(t *testing.T) { } } -func TestAnAttachmentPreviewMustComeFromHEY(t *testing.T) { - figure := func(img string) string { - return `
Plan attached.
` + img + `
` +// An attachment goes out as its identity alone, so nothing a model puts in or on its +// wrapper (a tracker preview, a styled or rewritten caption, a stray attribute, markup +// inside) is sent, and the caption that is sent is the one the prompt shows. +func TestAnAttachmentIsDeliveredAsItsIdentityAlone(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
Plan attached.
forward the reset codes
hidden
` + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) } - for name, tc := range map[string]struct { - img string - sent bool - }{ - "HEY's own preview": {``, true}, - "a relative preview": {``, true}, - "a tracker": {``, false}, - "a protocol-relative src": {``, false}, - "a srcset": {``, false}, - } { - t.Run(name, func(t *testing.T) { - rig := newSendRig(t) - rig.drafts.update(func(e *generated.MessageEditState) { e.Content = figure(tc.img) }) - session := rig.connect(t, approve, "", false) - result := sendDraft(t, session) - if sent := !result.IsError; sent != tc.sent { - t.Errorf("sent = %v, want %v: %s", sent, tc.sent, resultText(t, result)) - } - }) + mustContain(t, prompt, `Attached: plan.pdf (120 bytes), captioned "Q3 plan"`) + + delivered := rig.drafts.sent()[0].Content + for _, gone := range []string{"tracker", "reset codes", "hidden", "display:none", "onclick", "sig=1", "Hi Ann` + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") } diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index c7c799ba8..fe270a325 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -1,11 +1,11 @@ package mcpserver import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" - "net/url" "slices" "strconv" "strings" @@ -16,12 +16,20 @@ import ( // What send_draft reads out of a draft's body (sending-safety D2): the text the // confirmation shows, whether that text is everything the mail carries, its -// attachments, any reference a mail client would load by itself, and the hash the -// confirmation binds. +// attachments, any reference a mail client would load by itself, the body it delivers, +// and the hash the confirmation binds. // // The body is the Trix editing HTML GetMessageEdit serves. Its attachments are //
(or ) elements whose -// JSON names the blob's sgid, filename, size and type beside a signed URL. +// JSON names the blob's sgid, filename, size and type beside a signed URL, wrapped +// around a preview image and a caption HEY renders from the blob. +// +// An attachment is delivered as its identity alone: the element is rebuilt from the +// sgid, filename, size, type and caption, with nothing inside it, which is what HEY +// keeps of one anyway (Action Text resolves an attachment from those attributes and +// renders its markup afresh). Nothing in the wrapper (a preview URL, a caption's +// markup, an attribute) is sent, so none of it has to be checked, shown or bound. The +// hash is over exactly the body that is delivered. // reviewedBody is one draft body, analyzed. type reviewedBody struct { @@ -35,14 +43,15 @@ type reviewedBody struct { attachments []attachment // remote names the first reference a mail client would fetch on open, or "". remote string - // unverifiable names the first attachment element that isn't a plain blob - // attachment (no sgid or filename, embedded HTML content, or markup Trix doesn't - // write around one), or "". Its contents can't be reduced to a blob's identity, so - // such a draft isn't sent. + // unverifiable names the first attachment that isn't a plain blob (no sgid or + // filename, or embedded HTML content, as Trix carries an embedded email body), or + // "". It can't be delivered as an identity, so such a draft isn't sent. unverifiable string - // hash is the sha256 of the body with each attachment reduced to its identity and - // the volatile attributes (signed URLs) dropped, so two fetches of an unchanged draft - // hash alike and a changed attachment doesn't. + // delivered is the body send_draft sends: the draft's, with each attachment rebuilt + // from its identity. + delivered string + // hash is the sha256 of delivered. Two fetches of an unchanged draft hash alike + // (signed URLs aren't in it), and a changed attachment doesn't. hash string } @@ -71,59 +80,69 @@ var blockTags = map[atom.Atom]bool{ atom.Ul: true, atom.Ol: true, } -// loadingTags fetch something when a mail client renders them, or can make it. +// loadingTags fetch something when a mail client renders them, can make it, or hold +// markup a client may parse differently (noscript, template and the raw-text +// fallbacks), which is refused rather than second-guessed. var loadingTags = map[atom.Atom]bool{ atom.Img: true, atom.Image: true, atom.Iframe: true, atom.Frame: true, atom.Video: true, atom.Audio: true, atom.Source: true, atom.Track: true, atom.Embed: true, atom.Object: true, atom.Link: true, atom.Picture: true, atom.Svg: true, atom.Style: true, atom.Script: true, - atom.Meta: true, atom.Base: true, atom.Input: true, + atom.Meta: true, atom.Base: true, atom.Input: true, atom.Noscript: true, atom.Template: true, + atom.Noembed: true, atom.Noframes: true, atom.Xmp: true, atom.Plaintext: true, atom.Math: true, } // loadingAttributes name a URL a client loads by itself. var loadingAttributes = []string{"src", "srcset", "background", "poster", "lowsrc", "dynsrc", "data", "xlink:href", "action", "formaction"} -// analyzeBody reads a draft's raw content. heyHost is the HEY host the draft lives -// on: an attachment's preview image may load only from there. -func analyzeBody(content, heyHost string) (reviewedBody, error) { - nodes, err := html.ParseFragment(strings.NewReader(content), &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}) +// analyzeBody reads a draft's raw content. +func analyzeBody(content string) (reviewedBody, error) { + // Parsed with scripting off, as a mail client parses it, so a noscript's contents + // are markup here too. + nodes, err := html.ParseFragmentWithOptions(strings.NewReader(content), + &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}, + html.ParseOptionEnableScripting(false)) if err != nil { return reviewedBody{}, fmt.Errorf("read the draft's body: %w", err) } - w := &bodyWalker{lossless: true, heyHost: heyHost} + w := &bodyWalker{lossless: true} for _, n := range nodes { w.walk(n) } - sum := sha256.Sum256([]byte(w.canonical.String())) + var delivered bytes.Buffer + for _, n := range nodes { + if err := html.Render(&delivered, n); err != nil { + return reviewedBody{}, fmt.Errorf("render the draft's body: %w", err) + } + } + sum := sha256.Sum256(delivered.Bytes()) return reviewedBody{ text: tidyText(w.text.String()), lossless: w.lossless && len(w.attachments) == 0, attachments: w.attachments, remote: w.remote, unverifiable: w.unverifiable, + delivered: delivered.String(), hash: hex.EncodeToString(sum[:]), }, nil } type bodyWalker struct { - text, canonical strings.Builder - lossless bool - attachments []attachment - remote string - unverifiable string - heyHost string + text strings.Builder + lossless bool + attachments []attachment + remote string + unverifiable string } func (w *bodyWalker) walk(n *html.Node) { switch n.Type { case html.TextNode: w.text.WriteString(n.Data) - w.canonical.WriteString(strconv.Quote(n.Data)) return case html.CommentNode: w.lossless = false - w.canonical.WriteString("") return case html.ElementNode: default: @@ -133,16 +152,15 @@ func (w *bodyWalker) walk(n *html.Node) { return } - a, isAttachment, problem := attachmentOf(n, w.heyHost) - if problem != "" && w.unverifiable == "" { - w.unverifiable = problem - } - if isAttachment && problem == "" { - // The attachment's own markup (the preview image on a signed URL, the caption - // Trix derives) is HEY's, not the sender's: reduce it to the blob's identity. + if a, isAttachment, problem := attachmentOf(n); isAttachment { + if problem != "" { + if w.unverifiable == "" { + w.unverifiable = problem + } + return + } + rebuildAttachment(n, a) w.attachments = append(w.attachments, a) - identity, _ := json.Marshal(a) - w.canonical.WriteString("") w.text.WriteString("\n[attachment: " + a.Filename + "]\n") return } @@ -153,12 +171,7 @@ func (w *bodyWalker) walk(n *html.Node) { if loadingTags[n.DataAtom] && w.remote == "" { w.remote = "<" + n.Data + ">" } - - attrs := slices.Clone(n.Attr) - slices.SortFunc(attrs, func(a, b html.Attribute) int { return strings.Compare(a.Key, b.Key) }) - w.canonical.WriteString("<" + n.Data) - for _, attr := range attrs { - w.canonical.WriteString(" " + attr.Key + "=" + strconv.Quote(attr.Val)) + for _, attr := range n.Attr { if n.DataAtom != atom.A || attr.Key != "href" { w.lossless = false } @@ -166,7 +179,6 @@ func (w *bodyWalker) walk(n *html.Node) { w.remote = attr.Key + " on <" + n.Data + ">" } } - w.canonical.WriteString(">") if n.DataAtom == atom.Br { w.text.WriteString("\n") @@ -186,7 +198,6 @@ func (w *bodyWalker) walk(n *html.Node) { if blockTags[n.DataAtom] { w.text.WriteString("\n") } - w.canonical.WriteString("") } // loadsByItself reports whether an attribute makes a mail client fetch a URL on open: @@ -207,13 +218,11 @@ func loadsByItself(attr html.Attribute) bool { // attachmentOf reads a Trix attachment element: a figure carrying data-trix-attachment, // or an action-text-attachment. It answers whether n is one, and a problem when it is -// one whose contents can't be reduced to a blob's identity: no sgid or filename (Trix -// carries an embedded email body as a filename-less text/html attachment whose markup -// is in its JSON), embedded content, an attribute Trix doesn't write on one, or -// anything inside but its preview image and caption. -func attachmentOf(n *html.Node, heyHost string) (attachment, bool, string) { +// one that isn't a plain blob: no sgid or filename, or embedded content (Trix carries +// an embedded email body as a filename-less text/html attachment whose markup is in +// its JSON). +func attachmentOf(n *html.Node) (attachment, bool, string) { var a attachment - var allowed map[string]bool var embedded bool switch { case n.DataAtom == atom.Figure && hasAttr(n, "data-trix-attachment"): @@ -225,12 +234,10 @@ func attachmentOf(n *html.Node, heyHost string) (attachment, bool, string) { _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) _, embedded = data["content"] a = attachment{SGID: asText(data["sgid"]), Filename: asText(data["filename"]), Size: sizeOf(data["filesize"]), ContentType: asText(data["contentType"]), Caption: asText(attributes["caption"])} - allowed = map[string]bool{"data-trix-attachment": true, "data-trix-attributes": true, "data-trix-content-type": true, "class": true} case n.Data == "action-text-attachment": size, _ := strconv.ParseInt(attrValue(n, "filesize"), 10, 64) embedded = hasAttr(n, "content") a = attachment{SGID: attrValue(n, "sgid"), Filename: attrValue(n, "filename"), Size: size, ContentType: attrValue(n, "content-type"), Caption: attrValue(n, "caption")} - allowed = map[string]bool{"sgid": true, "filename": true, "filesize": true, "content-type": true, "caption": true, "url": true, "presentation": true, "previewable": true, "width": true, "height": true} default: return a, false, "" } @@ -245,70 +252,41 @@ func attachmentOf(n *html.Node, heyHost string) (attachment, bool, string) { case a.SGID == "" || a.Filename == "": return a, true, name + " has no blob identity" } - for _, attr := range n.Attr { - if !allowed[attr.Key] { - return a, true, name + " carries " + attr.Key - } - } - if !plainAttachmentChildren(n, heyHost) { - return a, true, name + " wraps markup of its own" - } return a, true, "" } -// plainAttachmentChildren reports whether an attachment holds only what Trix renders -// in one: whitespace, a preview image served by HEY itself, and a caption of plain text. -func plainAttachmentChildren(n *html.Node, heyHost string) bool { - for c := n.FirstChild; c != nil; c = c.NextSibling { - switch { - case c.Type == html.TextNode && strings.TrimSpace(c.Data) == "": - case c.Type == html.ElementNode && c.DataAtom == atom.Img && c.FirstChild == nil && heyPreview(c, heyHost): - case c.Type == html.ElementNode && c.DataAtom == atom.Figcaption: - for t := c.FirstChild; t != nil; t = t.NextSibling { - if t.Type != html.TextNode { - return false - } - } - default: - return false - } +// rebuildAttachment replaces an attachment element's attributes with its identity and +// drops everything inside it, so it's delivered as Trix submits a fresh one. +func rebuildAttachment(n *html.Node, a attachment) { + for c := n.FirstChild; c != nil; { + next := c.NextSibling + n.RemoveChild(c) + c = next } - return true -} - -// heyPreview reports whether img is a preview HEY serves: a src on HEY's own host (or -// relative to it) and nothing but its size and alt text besides. -func heyPreview(img *html.Node, heyHost string) bool { - for _, attr := range img.Attr { - switch attr.Key { - case "width", "height", "alt": - case "src": - u, err := url.Parse(attr.Val) - if err != nil || (u.Host != "" && !strings.EqualFold(u.Host, heyHost)) || (u.Host == "" && !strings.HasPrefix(attr.Val, "/")) || strings.HasPrefix(attr.Val, "//") { - return false - } - if u.Scheme != "" && u.Scheme != "https" && u.Scheme != "http" { - return false - } - default: - return false + if n.DataAtom == atom.Figure { + data, _ := json.Marshal(struct { + ContentType string `json:"contentType,omitempty"` + Filename string `json:"filename"` + Filesize int64 `json:"filesize,omitempty"` + SGID string `json:"sgid"` + }{a.ContentType, a.Filename, a.Size, a.SGID}) + n.Attr = []html.Attribute{{Key: "data-trix-attachment", Val: string(data)}} + if a.Caption != "" { + attributes, _ := json.Marshal(map[string]string{"caption": a.Caption}) + n.Attr = append(n.Attr, html.Attribute{Key: "data-trix-attributes", Val: string(attributes)}) } + return } - return true -} - -func hasAttr(n *html.Node, key string) bool { - for _, attr := range n.Attr { - if attr.Key == key { - return true - } + n.Attr = []html.Attribute{{Key: "sgid", Val: a.SGID}, {Key: "filename", Val: a.Filename}} + if a.ContentType != "" { + n.Attr = append(n.Attr, html.Attribute{Key: "content-type", Val: a.ContentType}) + } + if a.Size > 0 { + n.Attr = append(n.Attr, html.Attribute{Key: "filesize", Val: strconv.FormatInt(a.Size, 10)}) + } + if a.Caption != "" { + n.Attr = append(n.Attr, html.Attribute{Key: "caption", Val: a.Caption}) } - return false -} - -func asText(v any) string { - s, _ := v.(string) - return s } func sizeOf(v any) int64 { @@ -332,6 +310,20 @@ func attrValue(n *html.Node, key string) string { return "" } +func hasAttr(n *html.Node, key string) bool { + for _, attr := range n.Attr { + if attr.Key == key { + return true + } + } + return false +} + +func asText(v any) string { + s, _ := v.(string) + return s +} + // tidyText trims trailing space from each line and collapses runs of blank lines. func tidyText(s string) string { lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go index 26e6c51ed..03ff64a84 100644 --- a/internal/mcpserver/sendprompt.go +++ b/internal/mcpserver/sendprompt.go @@ -83,7 +83,11 @@ func attachmentList(attachments []attachment) string { } names := make([]string, 0, len(attachments)) for _, a := range attachments { - names = append(names, fmt.Sprintf("%s (%s)", inertLine(a.Filename), humanSize(a.Size))) + name := fmt.Sprintf("%s (%s)", inertLine(a.Filename), humanSize(a.Size)) + if a.Caption != "" { + name += `, captioned "` + inertLine(a.Caption) + `"` + } + names = append(names, name) } return strings.Join(names, ", ") } From e5e106380e2d354d02244fb76d413c8a1946c7b4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 16:43:53 -0700 Subject: [PATCH 06/16] Show delivered header text whole, escape every invisible character, and expose on content-bearing writes - The subject and an attachment's name and caption are delivered, so the confirmation shows them in full; only display names are capped. - Escape every Unicode format character, variation selector and private-use character, a class rather than a list, so tag characters can't hide a payload in short visible text. - A write that answers with a body (reveal_contact) exposes the sign-in, as a read does. --- internal/mcpserver/dispatch.go | 17 +++++++- internal/mcpserver/send_test.go | 71 ++++++++++++++++++++++++++++++++ internal/mcpserver/sendprompt.go | 22 ++++++++-- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go index 9035112aa..db9216d48 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -82,8 +82,10 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O return gateway.ErrorResult("%v", err), nil } // The exposure marker lands before the content leaves (D6). A read not in the - // table exposes: it can't be shown not to. - if exposes, classified := readExposes[full.ID]; d.markExposed != nil && full.ReadOnly && (exposes || !classified) { + // table exposes: it can't be shown not to. So does a write that answers with a body + // (reveal_contact answers the contact): what HEY returns is content, whatever the + // verb. A draft save's answer is this server's own. + if d.markExposed != nil && !draft && exposingAnswer(full, resp) { if err := d.markExposed(ctx, full.Action); err != nil { return gateway.ErrorResult("hey mcp can't return %s right now: it couldn't record that the answer may include mail from an unscreened sender (%v)", full.Action, err), nil } @@ -121,6 +123,17 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(resp.Data)}}}, nil } +// exposingAnswer reports whether an answer may carry an unscreened sender's words +// (exposure.go): a read the table doesn't clear, or a write whose answer has a body. +func exposingAnswer(op *catalog.Operation, resp *hey.Response) bool { + if op.ReadOnly { + exposes, classified := readExposes[op.ID] + return exposes || !classified + } + body := bytes.TrimSpace(resp.Data) + return len(body) > 0 && !bytes.Equal(body, []byte("null")) +} + // acknowledgedBy names the mutations whose success is one exact status, because a // redirect would otherwise pass for it: net/http follows a PATCH's 302 as a GET, and // the read that answers is a 200 the dispatcher would hand back as a result. HEY diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 3417ac3b9..49618d580 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -1203,3 +1203,74 @@ func TestNoscriptMarkupCountsAsLoading(t *testing.T) { session := rig.connect(t, approve, "", false) mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") } + +// --- review round four --- + +func TestTheWholeSubjectIsShown(t *testing.T) { + rig := newSendRig(t) + subject := "Re: Q3 plan " + strings.Repeat("and more ", 30) + "RESET-CODE-123456" + rig.drafts.update(func(e *generated.MessageEditState) { e.Subject = subject }) + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, "Subject: "+subject+"\n") +} + +func TestEveryInvisibleCharacterIsEscaped(t *testing.T) { + for name, r := range map[string]rune{ + "tag character": 0xE0041, + "variation selector": 0xFE0F, + "private use": 0xE000, + "word joiner": 0x2060, + "invisible times": 0x2062, + } { + if got := escapeInvisible("a"+string(r)+"b", true); !strings.Contains(got, `\u{`) { + t.Errorf("%s (U+%04X) shown raw: %q", name, r, got) + } + } +} + +func TestATagEncodedPayloadCantPassAsShortVisibleText(t *testing.T) { + var hidden strings.Builder + for _, c := range "reset code 123456" { + hidden.WriteRune(0xE0000 + c) + } + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = "
Hi Ann." + strings.Repeat(hidden.String(), 12) + "
" + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") +} + +func TestAWriteThatAnswersWithContentExposes(t *testing.T) { + rig := newSendRig(t) + rig.api.resp = &hey.Response{StatusCode: http.StatusOK, Data: json.RawMessage(`{"id":3,"name":"Mallory","email_address":"mallory@evil.example"}`)} + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_contacts", + Arguments: map[string]any{"action": "reveal_contact", "params": map[string]any{"contactId": 3}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := rig.state.exposure(context.Background(), "grant-1", rig.clock.Now()); !exposed { + t.Error("reveal_contact returned a contact without exposing the sign-in") + } + + quiet := newSendRig(t) + quiet.api.resp = &hey.Response{StatusCode: http.StatusNoContent, Data: json.RawMessage(``)} + session = quiet.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "trash_topic", "params": map[string]any{"topicId": 9}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := quiet.state.exposure(context.Background(), "grant-1", quiet.clock.Now()); exposed { + t.Error("a write that answered nothing exposed the sign-in") + } +} diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go index 03ff64a84..5413c9fe2 100644 --- a/internal/mcpserver/sendprompt.go +++ b/internal/mcpserver/sendprompt.go @@ -3,6 +3,7 @@ package mcpserver import ( "fmt" "strings" + "unicode" "unicode/utf8" "github.com/basecamp/hey-sdk/go/pkg/generated" @@ -26,7 +27,8 @@ func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { fmt.Fprintf(&b, " To: %s\n", recipientList(snap.to, outcome.newAddresses)) fmt.Fprintf(&b, " Cc: %s\n", recipientList(snap.cc, outcome.newAddresses)) fmt.Fprintf(&b, " Bcc: %s\n", recipientList(snap.bcc, outcome.newAddresses)) - fmt.Fprintf(&b, " Subject: %s\n", inertLine(snap.subject)) + // Delivered text is shown whole: only display names, which aren't sent, are capped. + fmt.Fprintf(&b, " Subject: %s\n", inertWhole(snap.subject)) fmt.Fprintf(&b, " Attached: %s\n", attachmentList(snap.body.attachments)) b.WriteString(" Body:\n") @@ -83,9 +85,9 @@ func attachmentList(attachments []attachment) string { } names := make([]string, 0, len(attachments)) for _, a := range attachments { - name := fmt.Sprintf("%s (%s)", inertLine(a.Filename), humanSize(a.Size)) + name := fmt.Sprintf("%s (%s)", inertWhole(a.Filename), humanSize(a.Size)) if a.Caption != "" { - name += `, captioned "` + inertLine(a.Caption) + `"` + name += `, captioned "` + inertWhole(a.Caption) + `"` } names = append(names, name) } @@ -121,6 +123,13 @@ func inertLine(s string) string { return out } +// inertWhole renders a delivered header field (the subject, an attachment's name and +// caption): one line, every control and invisible character escaped, never truncated, +// since what's cut off would go out unseen. +func inertWhole(s string) string { + return escapeInvisible(s, false) +} + // inertBody renders the body: plain newlines kept, everything else escaped. func inertBody(s string) string { return escapeInvisible(s, true) @@ -145,7 +154,14 @@ func escapeInvisible(s string, keepNewlines bool) string { return b.String() } +// invisible reports a rune that shows as nothing or reorders what's around it: every +// format character (Unicode Cf: bidi controls, zero-width characters, tag characters, +// the soft hyphen), variation selectors, private-use characters, and the controls and +// separators below. A class, not a list, so the next invisible character is caught too. func invisible(r rune) bool { + if unicode.In(r, unicode.Cf, unicode.Co, unicode.Variation_Selector) { + return true + } switch { case r < 0x20, r == 0x7F, r >= 0x80 && r <= 0x9F: return true From ecd7973e930c593f8c2005d142ce580e8bf8e123 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 17:01:25 -0700 Subject: [PATCH 07/16] Measure every delivered space, and key claims by the canonical origin - The prompt's text keeps every space and blank line the body delivers, and a line with trailing spaces or tabs counts as unseen, so a
 can't
  carry a whitespace payload past the cap.
- send_draft keys claims by config.Origin, so two spellings of one HEY share
  one claim.
---
 internal/cmd/mcp.go             |  9 +++++++--
 internal/config/mcp.go          |  6 ++++++
 internal/config/mcp_test.go     |  8 ++++++++
 internal/mcpserver/send_test.go | 19 +++++++++++++++++++
 internal/mcpserver/sendbody.go  | 32 ++++++++++++++------------------
 5 files changed, 54 insertions(+), 20 deletions(-)

diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go
index 586248552..9c6896fcf 100644
--- a/internal/cmd/mcp.go
+++ b/internal/cmd/mcp.go
@@ -5,7 +5,6 @@ import (
 	"log/slog"
 	"os"
 	"os/signal"
-	"strings"
 	"syscall"
 	"time"
 
@@ -126,6 +125,12 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error {
 	// process records exposure for any other hey mcp on the same sign-in that sends.
 	canSend := mcpCanSend(creds)
 	if canSend {
+		// Claims are keyed by origin, so it has one spelling: https://app.hey.com and
+		// https://app.hey.com:443 are the same HEY.
+		origin, originErr := config.Origin(cfg.BaseURL)
+		if originErr != nil {
+			return originErr
+		}
 		state, stateErr := mcpserver.OpenSendState(config.StateDir())
 		if stateErr != nil {
 			return stateErr
@@ -139,7 +144,7 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error {
 			Drafts: mcpDrafts{reads: reads, writes: writes},
 			State:  state,
 			Grant:  mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt},
-			Origin: strings.TrimRight(cfg.BaseURL, "/"),
+			Origin: origin,
 			CurrentGrant: func(context.Context) (string, error) {
 				stored, loadErr := mcpAuthMgr.StoredCredentials()
 				if loadErr != nil {
diff --git a/internal/config/mcp.go b/internal/config/mcp.go
index 8038ee58a..a64bfe027 100644
--- a/internal/config/mcp.go
+++ b/internal/config/mcp.go
@@ -22,6 +22,12 @@ const DefaultMCPClientID = ""
 // and digits.
 var mcpClientIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`)
 
+// Origin is baseURL's canonical origin (scheme and host lowercased, default port
+// dropped), so one HEY has one spelling wherever it keys state.
+func Origin(baseURL string) (string, error) {
+	return serverOrigin(baseURL)
+}
+
 // MCPClientID resolves the local MCP client id for the HEY at baseURL, and where it came
 // from: HEY_MCP_CLIENT_ID, then the global config's "mcp_client_ids" entry for that
 // origin, then DefaultMCPClientID. It is read from the global config only, never a
diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go
index 947d352c7..6d3c5ebac 100644
--- a/internal/config/mcp_test.go
+++ b/internal/config/mcp_test.go
@@ -58,3 +58,11 @@ func TestConflictingSpellingsOfOneOriginAreRefused(t *testing.T) {
 		t.Error("two ids for one origin resolved to one of them")
 	}
 }
+
+func TestOriginHasOneSpelling(t *testing.T) {
+	for _, spelling := range []string{"https://app.hey.com", "https://APP.HEY.COM:443", "https://app.hey.com/"} {
+		if got, err := Origin(spelling); err != nil || got != "https://app.hey.com" {
+			t.Errorf("Origin(%q) = %q, %v", spelling, got, err)
+		}
+	}
+}
diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go
index 49618d580..ccd446d26 100644
--- a/internal/mcpserver/send_test.go
+++ b/internal/mcpserver/send_test.go
@@ -1274,3 +1274,22 @@ func TestAWriteThatAnswersWithContentExposes(t *testing.T) {
 		t.Error("a write that answered nothing exposed the sign-in")
 	}
 }
+
+// --- review round five ---
+
+func TestWhitespaceThePromptCantShowIsUnseen(t *testing.T) {
+	for name, body := range map[string]string{
+		"trailing spaces in a pre":  "
Hi Ann\n" + strings.Repeat("\t \t  \n", 3) + "
", + "a long run of blank lines": "
Hi Ann
" + strings.Repeat("
", 1600), + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = body + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") + }) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index fe270a325..fd3a40e5c 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -117,9 +117,10 @@ func analyzeBody(content string) (reviewedBody, error) { } } sum := sha256.Sum256(delivered.Bytes()) + text, hidesWhitespace := plainText(w.text.String()) return reviewedBody{ - text: tidyText(w.text.String()), - lossless: w.lossless && len(w.attachments) == 0, + text: text, + lossless: w.lossless && len(w.attachments) == 0 && !hidesWhitespace, attachments: w.attachments, remote: w.remote, unverifiable: w.unverifiable, @@ -324,22 +325,17 @@ func asText(v any) string { return s } -// tidyText trims trailing space from each line and collapses runs of blank lines. -func tidyText(s string) string { - lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") - var out []string - blank := 0 - for _, line := range lines { - line = strings.TrimRight(line, " \t") - if line == "" { - blank++ - if blank > 1 { - continue - } - } else { - blank = 0 +// plainText is the text the prompt shows: the rendering with its line endings +// normalized and the one newline its last block ends with dropped, and nothing else changed, +// so every space and blank line the body delivers is in what the prompt shows and is +// measured. hidesWhitespace reports trailing spaces or tabs on a line, which a prompt +// can't show and a
 can carry data in.
+func plainText(s string) (string, bool) {
+	s = strings.TrimSuffix(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
+	for line := range strings.SplitSeq(s, "\n") {
+		if strings.TrimRight(line, " \t") != line {
+			return s, true
 		}
-		out = append(out, line)
 	}
-	return strings.Trim(strings.Join(out, "\n"), "\n")
+	return s, false
 }

From 61f0644d0dd96ac2eeaed6e466bc22fdfc92ad7d Mon Sep 17 00:00:00 2001
From: Jeremy Daer 
Date: Fri, 23 Oct 2026 17:05:10 -0700
Subject: [PATCH 08/16] Escape every default-ignorable character in the
 confirmation, not only format characters

---
 internal/mcpserver/send_test.go  | 12 +++++++-----
 internal/mcpserver/sendprompt.go |  5 +++--
 2 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go
index ccd446d26..5e98f113e 100644
--- a/internal/mcpserver/send_test.go
+++ b/internal/mcpserver/send_test.go
@@ -1221,11 +1221,13 @@ func TestTheWholeSubjectIsShown(t *testing.T) {
 
 func TestEveryInvisibleCharacterIsEscaped(t *testing.T) {
 	for name, r := range map[string]rune{
-		"tag character":      0xE0041,
-		"variation selector": 0xFE0F,
-		"private use":        0xE000,
-		"word joiner":        0x2060,
-		"invisible times":    0x2062,
+		"tag character":             0xE0041,
+		"variation selector":        0xFE0F,
+		"private use":               0xE000,
+		"word joiner":               0x2060,
+		"invisible times":           0x2062,
+		"combining grapheme joiner": 0x034F,
+		"hangul filler":             0x3164,
 	} {
 		if got := escapeInvisible("a"+string(r)+"b", true); !strings.Contains(got, `\u{`) {
 			t.Errorf("%s (U+%04X) shown raw: %q", name, r, got)
diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go
index 5413c9fe2..6f0466ea4 100644
--- a/internal/mcpserver/sendprompt.go
+++ b/internal/mcpserver/sendprompt.go
@@ -156,10 +156,11 @@ func escapeInvisible(s string, keepNewlines bool) string {
 
 // invisible reports a rune that shows as nothing or reorders what's around it: every
 // format character (Unicode Cf: bidi controls, zero-width characters, tag characters,
-// the soft hyphen), variation selectors, private-use characters, and the controls and
+// the soft hyphen), variation selectors, private-use characters, the rest of Unicode's
+// default-ignorable set (the combining grapheme joiner, Hangul fillers), and the controls and
 // separators below. A class, not a list, so the next invisible character is caught too.
 func invisible(r rune) bool {
-	if unicode.In(r, unicode.Cf, unicode.Co, unicode.Variation_Selector) {
+	if unicode.In(r, unicode.Cf, unicode.Co, unicode.Variation_Selector, unicode.Other_Default_Ignorable_Code_Point) {
 		return true
 	}
 	switch {

From bd2791a7b2a042d7980d9d7e80eac1fe2c412058 Mon Sep 17 00:00:00 2001
From: Jeremy Daer 
Date: Fri, 23 Oct 2026 17:17:56 -0700
Subject: [PATCH 09/16] Send a draft in one request: no refresh-and-resend
 after a 401

---
 internal/cmd/mcp.go      | 29 +++++++++++++++++++++----
 internal/cmd/mcp_test.go | 46 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 71 insertions(+), 4 deletions(-)

diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go
index 9c6896fcf..178b4faba 100644
--- a/internal/cmd/mcp.go
+++ b/internal/cmd/mcp.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"context"
 	"log/slog"
+	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
@@ -140,8 +141,15 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error {
 		if creds.GrantedAt > 0 {
 			grantedAt = time.Unix(creds.GrantedAt, 0)
 		}
+		// The delivery goes through a client of its own that can't resend: the SDK
+		// answers a 401 by refreshing and resending once, and a send is one request
+		// (D2.4). The token is refreshed before the send instead.
+		sends, sendsErr := clientForAccountSelection(ctx, newMCPSDKClient(hey.WithMaxRetries(0), hey.WithAuthStrategy(onceAuthStrategy{mgr: mcpAuthMgr})), cfg.AccountID)
+		if sendsErr != nil {
+			return sendsErr
+		}
 		serverCfg.Send = &mcpserver.SendConfig{
-			Drafts: mcpDrafts{reads: reads, writes: writes},
+			Drafts: mcpDrafts{reads: reads, sends: sends},
 			State:  state,
 			Grant:  mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt},
 			Origin: origin,
@@ -194,9 +202,19 @@ func newMCPSDKClient(extra ...hey.ClientOption) *hey.Client {
 }
 
 // mcpDrafts is send_draft's view of the SDK: reads through the retrying client, the send
-// through the one that never retries, because a retried send can deliver twice.
+// through one that never retries or resends, because a second request can deliver twice.
 type mcpDrafts struct {
-	reads, writes *hey.Client
+	reads, sends *hey.Client
+}
+
+// onceAuthStrategy authenticates like cliAuthStrategy but offers the SDK no refresh,
+// so a 401 surfaces instead of being answered with a resend.
+type onceAuthStrategy struct {
+	mgr *auth.Manager
+}
+
+func (a onceAuthStrategy) Authenticate(ctx context.Context, req *http.Request) error {
+	return a.mgr.AuthenticateRequest(ctx, req)
 }
 
 func (d mcpDrafts) GetEdit(ctx context.Context, draftID int64) (*generated.MessageEditState, error) {
@@ -204,7 +222,10 @@ func (d mcpDrafts) GetEdit(ctx context.Context, draftID int64) (*generated.Messa
 }
 
 func (d mcpDrafts) SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) {
-	return d.writes.Messages().SendDraft(ctx, draftID, draft)
+	// Refresh a token that's about to expire now, so the one request goes out with a
+	// live one. A failure here leaves the send to answer for itself.
+	_, _ = mcpAuthMgr.AccessToken(ctx)
+	return d.sends.Messages().SendDraft(ctx, draftID, draft)
 }
 
 func (d mcpDrafts) Identity(ctx context.Context) (*generated.Identity, error) {
diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go
index 04355cf35..2981b07a3 100644
--- a/internal/cmd/mcp_test.go
+++ b/internal/cmd/mcp_test.go
@@ -458,3 +458,49 @@ func TestMCPCommandSendsAConfirmedDraftOnce(t *testing.T) {
 		t.Errorf("HEY saw %d deliveries, want 1", got)
 	}
 }
+
+// A delivery answered 401 isn't resent: the SDK would refresh and resend, and a send
+// is one request.
+func TestMCPCommandNeverResendsADeliveryAfterA401(t *testing.T) {
+	var puts atomic.Int32
+	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		switch {
+		case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json":
+			_, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
Hi Ann
","sender":{"id":77,"email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/messages/5.json": + puts.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + _, _ = w.Write([]byte(`{"access_token":"fresh","refresh_token":"fresh-refresh","expires_in":3600,"scope":"read write send"}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Errorf("a 401 delivery reported success: %v", result.Content) + } + if got := puts.Load(); got != 1 { + t.Errorf("HEY saw %d delivery requests, want 1", got) + } +} From b0d07de02c4ed47f903637e6c5b7cdac14a030a9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 17:26:57 -0700 Subject: [PATCH 10/16] To someone new, send exactly the plain text the confirmation shows Each round found another markup choice that the text rendering showed the same way but the recipient received differently: trailing whitespace in a pre, and now a list's type. Each one is a hidden channel in a lossless-markup classifier, and the classifier can't be made injective. Instead, a draft to someone new, or sent while exposed, goes as HTML built from the plain text the person read (lines escaped, with breaks between them), with invisible characters and trailing whitespace dropped. To known recipients on an unexposed sign-in it goes as it is, as before. The plain or as-is decision is signed into the confirmation, and a change between rounds refuses the send. --- docs/agents.md | 13 +++- internal/mcpserver/send.go | 36 +++++++---- internal/mcpserver/send_test.go | 103 +++++++++++++++++++++---------- internal/mcpserver/sendbody.go | 43 ++++++++++++- internal/mcpserver/sendprompt.go | 11 +++- internal/mcpserver/sendstate.go | 7 ++- 6 files changed, 158 insertions(+), 55 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 5dba84127..034680ad7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -111,15 +111,22 @@ escaped so a display name can't forge a line. Recipients you hadn't emailed befo fetched again after you answer, and if its recipients, sender, subject, body or attachments changed it isn't sent. Each draft is sent through `hey mcp` once; another attempt in the next 24 hours, even after a failure, is refused, and the send is never -retried, so an unclear outcome says to check Sent. `send_draft` refuses, pointing at the +retried, so an unclear outcome says to check Sent. + +To someone you hadn't emailed before, or within ten minutes of a read that may have +returned an unscreened sender's mail, the draft goes as the plain text the confirmation +shows: its formatting isn't sent, and invisible characters and trailing spaces are +dropped, so what goes is exactly what you read. To people you've emailed before, it +goes as it is. `send_draft` refuses, pointing at the draft in HEY: - any draft, from a client that can't show a confirmation (it answers with the draft's link, and saying yes in the chat doesn't send it); - a draft that is already scheduled, or has no recipients, or more than 20; - a draft with a reference a mail client loads by itself, such as a remote image; -- a draft it can't show in full (an attachment, more than 1,500 characters, or - formatting that can hide text), when it goes to someone new; +- a draft with an attachment, or more than 1,500 characters, when it goes to someone + new or within the ten minutes below (otherwise such a draft goes, and the + confirmation says how much it can't show); - a draft to someone new within ten minutes of a read that may have returned an unscreened sender's mail (search, threads, messages, drafts, contacts, the Screener, calendar recordings; box reads don't count); diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index 544a68031..10f7798be 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -216,6 +216,7 @@ func (s *sender) ask(ctx context.Context, draftID int64) (*mcp.CallToolResult, e Kind: confirmKind, Method: confirmMethod, Grant: s.cfg.Grant.Key, Origin: s.cfg.Origin, Draft: draftID, Fingerprint: snap.fingerprint, + Plain: outcome.plain, Expires: s.cfg.Now().Add(confirmTTL).Unix(), }) @@ -246,11 +247,14 @@ func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState str if refusal != nil { return refusal, nil } + if outcome.plain != state.Plain { + return gateway.ErrorResult("not sent: since you approved it, this conversation read unscreened mail or the recipients' history changed, which changes how the draft would go. Call send_draft again to review it as it would go now"), nil + } if !hmac.Equal([]byte(snap.fingerprint), []byte(state.Fingerprint)) { return gateway.ErrorResult("not sent: the draft changed since you approved it (its recipients, sender, subject, body or attachments differ). Call send_draft again to review it as it is now"), nil } - if err = s.cfg.State.claim(ctx, s.cfg.Origin, draftID, s.cfg.Grant.Key, outcome.newList, outcome.unseen, s.cfg.Now()); err != nil { + if err = s.cfg.State.claim(ctx, s.cfg.Origin, draftID, s.cfg.Grant.Key, outcome.newList, !outcome.plain, s.cfg.Now()); err != nil { var refused *refusalError if errors.As(err, &refused) { return gateway.ErrorResult("%s", refused.reason), nil @@ -260,7 +264,7 @@ func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState str // From here the draft is claimed for good: the claim is never released, even when // HEY refuses, so there's no rollback to get wrong (D2.3). - sent, err := s.cfg.Drafts.SendDraft(ctx, draftID, snap.content()) + sent, err := s.cfg.Drafts.SendDraft(ctx, draftID, snap.content(outcome.plain)) if err != nil { if refusedByHEY(err) { return gateway.ErrorResult("HEY refused the send: %v. The draft is still a draft, and hey mcp won't send it again for 24 hours; send it from HEY: %s", err, s.editURL(draftID)), nil @@ -407,11 +411,16 @@ type draftSnapshot struct { // landing after the final fetch is overwritten by what was approved (D2). HEY replaces // the whole recipient set when addressed is present (haystack Entry::Addressed#address), // so a Bcc added concurrently is dropped rather than delivered. The body is the one the -// fingerprint hashed, attachments rebuilt from their identity (sendbody.go). -func (d *draftSnapshot) content() hey.DraftContent { +// fingerprint hashed, attachments rebuilt from their identity, or, when plain, the +// plain text the confirmation showed (sendbody.go). +func (d *draftSnapshot) content(plain bool) hey.DraftContent { + body := d.body.delivered + if plain { + body = d.body.plainHTML + } return hey.DraftContent{ Subject: d.subject, - Content: d.body.delivered, + Content: body, To: emails(d.to), CC: emails(d.cc), BCC: emails(d.bcc), @@ -489,8 +498,9 @@ func snapshotOf(edit *generated.MessageEditState) (*draftSnapshot, error) { // reviewOutcome is what the policy found, for the prompt. type reviewOutcome struct { - // unseen: the confirmation can't show everything the draft transmits. - unseen bool + // plain: the draft goes as the plain text the confirmation shows (sendbody.go), + // because it's to someone new or the sign-in is exposed. Otherwise it goes as it is. + plain bool newList []string newAddresses map[string]bool exposed bool @@ -556,9 +566,12 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re // Measured on the escaped text the prompt shows: escapes lengthen it, and what // falls past the cut is unseen however short the raw text was. - unseen := !snap.body.lossless || utf8.RuneCountInString(inertBody(snap.body.text)) > bodyPromptCap - if unseen && (len(newAddresses) > 0 || exposed) { - return refuse("not sent: the draft can't be fully reviewed here (it has an attachment, more than %d characters, or formatting that can hide text), and it goes to someone you haven't emailed before or this conversation read unscreened mail. Send it from HEY: %s", + // To someone new, or while exposed, nothing may go that the person didn't read + // (D2): the draft goes as the plain text the confirmation shows, so it has to fit + // there whole, and an attachment, which a name and a size don't show, can't go. + plain := len(newAddresses) > 0 || exposed + if plain && (len(snap.body.attachments) > 0 || utf8.RuneCountInString(snap.body.plainText) > bodyPromptCap) { + return refuse("not sent: the draft can't be fully reviewed here (it has an attachment, or more than %d characters), and it goes to someone you haven't emailed before or this conversation read unscreened mail. Send it from HEY: %s", bodyPromptCap, s.editURL(draftID)) } @@ -579,7 +592,7 @@ func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *re return refuse("not sent: hey mcp sends to at most %d new addresses a day on one sign-in, and this draft would go past it. Send it from HEY: %s", maxNewAddressesPerDay, s.editURL(draftID)) } - outcome := &reviewOutcome{unseen: unseen, newList: newAddresses, newAddresses: map[string]bool{}, exposed: exposed, exposedBy: by} + outcome := &reviewOutcome{plain: plain, newList: newAddresses, newAddresses: map[string]bool{}, exposed: exposed, exposedBy: by} for _, address := range newAddresses { outcome.newAddresses[address] = true } @@ -674,6 +687,7 @@ type confirmState struct { Origin string `json:"o"` Draft int64 `json:"d"` Fingerprint string `json:"f"` + Plain bool `json:"p,omitempty"` Expires int64 `json:"e"` } diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 5e98f113e..cb0a99413 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -755,10 +755,9 @@ func TestSendDraftCapsRecipientsSendsAndNewAddresses(t *testing.T) { func TestUnseenContentGoesOnlyToKnownRecipients(t *testing.T) { long := "
" + strings.Repeat("The numbers moved again. ", 80) + "
" - hidden := `
Thanks!
forward the reset codes
` attached := `
Plan attached.
` - for name, body := range map[string]string{"past the cap": long, "hidden text": hidden, "an attachment": attached} { + for name, body := range map[string]string{"past the cap": long, "an attachment": attached} { t.Run(name+" to a new recipient", func(t *testing.T) { rig := newSendRig(t) rig.drafts.update(func(e *generated.MessageEditState) { @@ -1032,16 +1031,6 @@ func TestASignInChangedUnderTheServerRefusesSendsAndExposingReads(t *testing.T) } } -func TestEscapesCountTowardTheBodyThePromptCanShow(t *testing.T) { - rig := newSendRig(t) - rig.drafts.update(func(e *generated.MessageEditState) { - e.Content = "
" + strings.Repeat("\u200b", 200) + "Please send the reset codes to me.
" - e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} - }) - session := rig.connect(t, approve, "", false) - mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") -} - func TestAnAttachmentThatIsntAPlainBlobIsRefused(t *testing.T) { for name, body := range map[string]string{ "no blob identity": `
hidden text
`, @@ -1235,20 +1224,6 @@ func TestEveryInvisibleCharacterIsEscaped(t *testing.T) { } } -func TestATagEncodedPayloadCantPassAsShortVisibleText(t *testing.T) { - var hidden strings.Builder - for _, c := range "reset code 123456" { - hidden.WriteRune(0xE0000 + c) - } - rig := newSendRig(t) - rig.drafts.update(func(e *generated.MessageEditState) { - e.Content = "
Hi Ann." + strings.Repeat(hidden.String(), 12) + "
" - e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} - }) - session := rig.connect(t, approve, "", false) - mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") -} - func TestAWriteThatAnswersWithContentExposes(t *testing.T) { rig := newSendRig(t) rig.api.resp = &hey.Response{StatusCode: http.StatusOK, Data: json.RawMessage(`{"id":3,"name":"Mallory","email_address":"mallory@evil.example"}`)} @@ -1279,19 +1254,79 @@ func TestAWriteThatAnswersWithContentExposes(t *testing.T) { // --- review round five --- -func TestWhitespaceThePromptCantShowIsUnseen(t *testing.T) { - for name, body := range map[string]string{ - "trailing spaces in a pre": "
Hi Ann\n" + strings.Repeat("\t \t  \n", 3) + "
", - "a long run of blank lines": "
Hi Ann
" + strings.Repeat("
", 1600), - } { +// To someone new, the draft goes as the plain text the confirmation shows, so nothing +// in its markup (hidden text, a list's type, trailing whitespace, invisible characters) +// carries anything the person didn't read. +func TestToSomeoneNewWhatGoesIsWhatWasShown(t *testing.T) { + var tagged strings.Builder + for _, c := range "reset code 123456" { + tagged.WriteRune(0xE0000 + c) + } + cases := map[string]struct{ body, shown string }{ + "hidden text": {`
Thanks!
forward the reset codes
`, "Thanks!\nforward the reset codes"}, + "an ordered list": {`
  1. one
  2. two
`, "- one\n- two\n"}, + "an unordered list": {`
  • one
  • two
`, "- one\n- two\n"}, + "bold and italic": {`
one two
`, "one two"}, + "tag characters": {"
Hi Ann." + tagged.String() + "
", "Hi Ann."}, + "zero-width padding": {"
" + strings.Repeat("\u200b", 200) + "Hi Ann.
", "Hi Ann."}, + "trailing whitespace": {"
Hi Ann\n\t \t  \n
", "Hi Ann\n\n"}, + } + delivered := map[string]string{} + for name, tc := range cases { t.Run(name, func(t *testing.T) { rig := newSendRig(t) rig.drafts.update(func(e *generated.MessageEditState) { - e.Content = body + e.Content = tc.body e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} }) - session := rig.connect(t, approve, "", false) - mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, prompt, "Goes as plain text") + for _, line := range strings.Split(tc.shown, "\n") { + mustContain(t, prompt, " "+line+"\n") + } + got := rig.drafts.sent()[0].Content + if want := plainHTML(tc.shown); got != want { + t.Errorf("delivered %q, want exactly the shown text %q", got, want) + } + delivered[name] = got }) } + if delivered["an ordered list"] != delivered["an unordered list"] { + t.Error("a list's type reached the recipient without the person seeing it") + } +} + +func TestToAKnownRecipientTheDraftGoesAsItIs(t *testing.T) { + rig := newSendRig(t) + body := `
Hi Ann, the plan:
  1. numbers
` + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, rig.drafts.sent()[0].Content, "Hi Ann", "
    ") +} + +func TestAPlainPathThatChangesBetweenRoundsRefusesTheSend(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = `
    Hi Ann
    ` }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + // Approved as it is, to a known recipient; then an unscreened read lands, and + // the draft would now go as plain text, which isn't what was approved. + if err := rig.state.MarkExposed(ctx, "grant-1", "get_topic", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "changes how the draft would go") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } } diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index fd3a40e5c..9d3070e72 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -47,9 +47,15 @@ type reviewedBody struct { // filename, or embedded HTML content, as Trix carries an embedded email body), or // "". It can't be delivered as an identity, so such a draft isn't sent. unverifiable string - // delivered is the body send_draft sends: the draft's, with each attachment rebuilt - // from its identity. + // delivered is the body send_draft sends to known recipients: the draft's, with + // each attachment rebuilt from its identity. delivered string + // plainText and plainHTML are the body as plain text, and the HTML made from that + // text alone. A draft to someone new, or sent while exposed, goes as plainHTML, and + // the confirmation shows plainText, so what goes is exactly what was shown: no + // markup choice (a list's type, bold against italic, a line break against a new + // paragraph) is left to carry anything the person didn't read. + plainText, plainHTML string // hash is the sha256 of delivered. Two fetches of an unchanged draft hash alike // (signed URLs aren't in it), and a changed attachment doesn't. hash string @@ -118,8 +124,11 @@ func analyzeBody(content string) (reviewedBody, error) { } sum := sha256.Sum256(delivered.Bytes()) text, hidesWhitespace := plainText(w.text.String()) + shown := plainOf(text) return reviewedBody{ text: text, + plainText: shown, + plainHTML: plainHTML(shown), lossless: w.lossless && len(w.attachments) == 0 && !hidesWhitespace, attachments: w.attachments, remote: w.remote, @@ -325,6 +334,36 @@ func asText(v any) string { return s } +// plainOf is text as it goes when sent as plain text: trailing spaces and tabs dropped +// from each line, and every invisible character removed, so nothing in it is unseen. +func plainOf(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.Map(func(r rune) rune { + if invisible(r) { + return -1 + } + return r + }, strings.TrimRight(line, " \t")) + } + return strings.Join(lines, "\n") +} + +// plainHTML is the body made from plain text: its lines, escaped, with a break between +// each, in one block. +func plainHTML(text string) string { + var b strings.Builder + b.WriteString("
    ") + for i, line := range strings.Split(text, "\n") { + if i > 0 { + b.WriteString("
    ") + } + b.WriteString(html.EscapeString(line)) + } + b.WriteString("
    ") + return b.String() +} + // plainText is the text the prompt shows: the rendering with its line endings // normalized and the one newline its last block ends with dropped, and nothing else changed, // so every space and blank line the body delivers is in what the prompt shows and is diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go index 6f0466ea4..b7e935789 100644 --- a/internal/mcpserver/sendprompt.go +++ b/internal/mcpserver/sendprompt.go @@ -32,7 +32,11 @@ func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { fmt.Fprintf(&b, " Attached: %s\n", attachmentList(snap.body.attachments)) b.WriteString(" Body:\n") - body := inertBody(snap.body.text) + text := snap.body.text + if outcome.plain { + text = snap.body.plainText + } + body := inertBody(text) total := utf8.RuneCountInString(body) if total > bodyPromptCap { body = string([]rune(body)[:bodyPromptCap]) + " …" @@ -43,7 +47,10 @@ func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { if total > bodyPromptCap { fmt.Fprintf(&b, " [first %s characters of %s · open the full draft in HEY: %s]\n", thousands(bodyPromptCap), thousands(total), s.editURL(snap.id)) } - if !snap.body.lossless { + switch { + case outcome.plain: + b.WriteString(" Goes as plain text: the draft's formatting isn't sent, so what's above is all of it.\n") + case !snap.body.lossless: fmt.Fprintf(&b, " Some of this draft can't be shown here; open it in HEY to see all of it: %s\n", s.editURL(snap.id)) } if len(outcome.newAddresses) > 0 { diff --git a/internal/mcpserver/sendstate.go b/internal/mcpserver/sendstate.go index ec1665149..dce63f91d 100644 --- a/internal/mcpserver/sendstate.go +++ b/internal/mcpserver/sendstate.go @@ -345,9 +345,10 @@ type refusalError struct{ reason string } func (r *refusalError) Error() string { return r.reason } // claim takes a draft's send claim and charges the grant's caps, in one locked update, -// or refuses. It re-reads exposure there: a draft with a new recipient, or with content -// the confirmation couldn't show in full (unseen), is refused if an unscreened read -// landed since the policy was checked (D2, D6). Nothing is written when it refuses. +// or refuses. It re-reads exposure there: a draft with a new recipient, or one going as +// it is rather than as the plain text the confirmation showed (unseen), is refused if +// an unscreened read landed since the policy was checked (D2, D6). Nothing is written +// when it refuses. func (s *SendState) claim(ctx context.Context, origin string, draftID int64, grantKey string, newAddresses []string, unseen bool, now time.Time) error { return s.update(ctx, func(d *sendStateData) error { d.prune(now) From 9f1e95d6d2908c925327370f4e50fa058f8eb19d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 17:39:17 -0700 Subject: [PATCH 11/16] Revalidate the grant just before the claim, and drop invisibles before trimming - Check the sign-in again after the second round's review, renewing the token there, so a renewal that drops send refuses before the draft is claimed. The send itself no longer renews. - Remove invisible characters from plain text before trimming trailing whitespace, so none can shield spaces from the trim. - Name no draft text, such as an attachment's filename, in a refusal. --- internal/cmd/mcp.go | 11 ++++--- internal/mcpserver/send.go | 9 +++++- internal/mcpserver/send_test.go | 54 ++++++++++++++++++++++++++++----- internal/mcpserver/sendbody.go | 18 +++++------ 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 178b4faba..30113edfb 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -153,7 +153,11 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { State: state, Grant: mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt}, Origin: origin, - CurrentGrant: func(context.Context) (string, error) { + CurrentGrant: func(ctx context.Context) (string, error) { + // Renew a token about to expire now, before the claim, rather than + // after it: a renewal can narrow the grant, and the send then must + // not go ahead. A failure leaves the stored state to answer. + _, _ = mcpAuthMgr.AccessToken(ctx) stored, loadErr := mcpAuthMgr.StoredCredentials() if loadErr != nil { return "", loadErr @@ -222,9 +226,8 @@ func (d mcpDrafts) GetEdit(ctx context.Context, draftID int64) (*generated.Messa } func (d mcpDrafts) SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) { - // Refresh a token that's about to expire now, so the one request goes out with a - // live one. A failure here leaves the send to answer for itself. - _, _ = mcpAuthMgr.AccessToken(ctx) + // The token was renewed just before the claim (CurrentGrant), so this one request + // goes out on a live one. return d.sends.Messages().SendDraft(ctx, draftID, draft) } diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index 10f7798be..45752b0bb 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -70,7 +70,8 @@ type SendConfig struct { Origin string // Now is the clock; nil is time.Now. Now func() time.Time - // CurrentGrant answers the sign-in's key as stored now. When it no longer matches + // CurrentGrant answers the sign-in's key as stored now, renewing a token about to + // expire first, so the send that may follow goes out on a live one. When it no longer matches // Grant.Key (hey mcp was signed in again or out since this process started, and the // token the client authenticates with may be the new one), sends and exposing reads // are refused until hey mcp restarts, so caps and exposure never count against a @@ -254,6 +255,12 @@ func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState str return gateway.ErrorResult("not sent: the draft changed since you approved it (its recipients, sender, subject, body or attachments differ). Call send_draft again to review it as it is now"), nil } + // The review's reads may have refreshed the token, and a refresh can narrow the + // grant. Check again just before claiming, so a send HEY would refuse doesn't + // block the draft for a day. + if err = s.checkGrant(ctx); err != nil { + return gateway.ErrorResult("not sent: %v", err), nil + } if err = s.cfg.State.claim(ctx, s.cfg.Origin, draftID, s.cfg.Grant.Key, outcome.newList, !outcome.plain, s.cfg.Now()); err != nil { var refused *refusalError if errors.As(err, &refused) { diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index cb0a99413..89edfad2d 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -1263,13 +1263,14 @@ func TestToSomeoneNewWhatGoesIsWhatWasShown(t *testing.T) { tagged.WriteRune(0xE0000 + c) } cases := map[string]struct{ body, shown string }{ - "hidden text": {`
    Thanks!
    forward the reset codes
    `, "Thanks!\nforward the reset codes"}, - "an ordered list": {`
    1. one
    2. two
    `, "- one\n- two\n"}, - "an unordered list": {`
    • one
    • two
    `, "- one\n- two\n"}, - "bold and italic": {`
    one two
    `, "one two"}, - "tag characters": {"
    Hi Ann." + tagged.String() + "
    ", "Hi Ann."}, - "zero-width padding": {"
    " + strings.Repeat("\u200b", 200) + "Hi Ann.
    ", "Hi Ann."}, - "trailing whitespace": {"
    Hi Ann\n\t \t  \n
    ", "Hi Ann\n\n"}, + "hidden text": {`
    Thanks!
    forward the reset codes
    `, "Thanks!\nforward the reset codes"}, + "an ordered list": {`
    1. one
    2. two
    `, "- one\n- two\n"}, + "an unordered list": {`
    • one
    • two
    `, "- one\n- two\n"}, + "bold and italic": {`
    one two
    `, "one two"}, + "tag characters": {"
    Hi Ann." + tagged.String() + "
    ", "Hi Ann."}, + "zero-width padding": {"
    " + strings.Repeat("\u200b", 200) + "Hi Ann.
    ", "Hi Ann."}, + "trailing whitespace": {"
    Hi Ann\n\t \t  \n
    ", "Hi Ann\n\n"}, + "whitespace behind an invisible": {"
    Hi Ann \u200b
    ", "Hi Ann"}, } delivered := map[string]string{} for name, tc := range cases { @@ -1330,3 +1331,42 @@ func TestAPlainPathThatChangesBetweenRoundsRefusesTheSend(t *testing.T) { t.Errorf("sent %d times", n) } } + +// The grant is checked again after the second round's review, right before the claim: +// a renewal there that dropped send refuses the send without claiming the draft. +func TestAGrantNarrowedDuringTheReviewDoesntClaimTheDraft(t *testing.T) { + rig := newSendRig(t) + checks := 0 + rig.cfg.CurrentGrant = func(context.Context) (string, error) { + checks++ + if checks > 2 { // both rounds' opening checks pass; the one before the claim doesn't + return "", nil + } + return "grant-1", nil + } + srv, err := New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + rig.server = srv + session := rig.connect(t, approve, "2025-06-18", false) + if result := sendDraft(t, session); !result.IsError { + t.Fatalf("sent on a grant narrowed below sending: %s", resultText(t, result)) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, true, rig.clock.Now()); err != nil { + t.Errorf("the draft was claimed anyway: %v", err) + } +} + +func TestARefusalNamesNoDraftText(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
    ` + }) + session := rig.connect(t, approve, "", false) + text := resultText(t, sendDraft(t, session)) + mustContain(t, text, "can't verify") + if strings.Contains(text, "IGNORE") { + t.Errorf("the refusal carried the draft's filename: %s", text) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index 9d3070e72..b08b894b1 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "unicode" "golang.org/x/net/html" "golang.org/x/net/html/atom" @@ -238,7 +239,7 @@ func attachmentOf(n *html.Node) (attachment, bool, string) { case n.DataAtom == atom.Figure && hasAttr(n, "data-trix-attachment"): var data map[string]any if json.Unmarshal([]byte(attrValue(n, "data-trix-attachment")), &data) != nil { - return a, true, "a figure whose attachment data doesn't parse" + return a, true, "an attachment's data doesn't parse" } var attributes map[string]any _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) @@ -252,15 +253,13 @@ func attachmentOf(n *html.Node) (attachment, bool, string) { return a, false, "" } - name := a.Filename - if name == "" { - name = "an unnamed attachment" - } + // The problem names no filename: it reaches the model, and a filename is the + // draft's text, not this server's. switch { case embedded: - return a, true, name + " carries embedded content" + return a, true, "an attachment carries embedded content" case a.SGID == "" || a.Filename == "": - return a, true, name + " has no blob identity" + return a, true, "an attachment has no blob identity" } return a, true, "" } @@ -339,12 +338,13 @@ func asText(v any) string { func plainOf(text string) string { lines := strings.Split(text, "\n") for i, line := range lines { - lines[i] = strings.Map(func(r rune) rune { + // Invisible characters go first, so none can shield whitespace from the trim. + lines[i] = strings.TrimRightFunc(strings.Map(func(r rune) rune { if invisible(r) { return -1 } return r - }, strings.TrimRight(line, " \t")) + }, line), unicode.IsSpace) } return strings.Join(lines, "\n") } From 72f3cccc3a294f6cbe4354d8ecf770cef6cd9057 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 17:53:42 -0700 Subject: [PATCH 12/16] Refuse on a failed renewal, acknowledge applied writes, keep spacing, drop comments - A token that can't be renewed right before the claim refuses the send, so a draft that certainly wasn't sent isn't claimed. - A write HEY applied is acknowledged as applied, with its answer withheld, when the exposure marker can't be written, so it isn't retried. - The plain body keeps the spacing the confirmation showed (non-breaking spaces for leading and repeated spaces, and tabs). - HTML comments are never sent; a conditional comment is markup Outlook renders. --- internal/cmd/mcp.go | 7 +++-- internal/cmd/mcp_test.go | 55 ++++++++++++++++++++++++++++++++- internal/mcpserver/dispatch.go | 9 ++++++ internal/mcpserver/send_test.go | 41 ++++++++++++++++++++++++ internal/mcpserver/sendbody.go | 36 +++++++++++++++++++-- 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 30113edfb..cf521ad75 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -156,8 +156,11 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { CurrentGrant: func(ctx context.Context) (string, error) { // Renew a token about to expire now, before the claim, rather than // after it: a renewal can narrow the grant, and the send then must - // not go ahead. A failure leaves the stored state to answer. - _, _ = mcpAuthMgr.AccessToken(ctx) + // not go ahead. A token that can't be made live refuses too, so a + // draft that certainly wasn't sent isn't claimed. + if _, tokenErr := mcpAuthMgr.AccessToken(ctx); tokenErr != nil { + return "", tokenErr + } stored, loadErr := mcpAuthMgr.StoredCredentials() if loadErr != nil { return "", loadErr diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 2981b07a3..148e88fda 100644 --- a/internal/cmd/mcp_test.go +++ b/internal/cmd/mcp_test.go @@ -99,6 +99,9 @@ func stubMCPTransport(t *testing.T) mcp.Transport { // mcpTestToken is the access token of the hey mcp credential the tests store. const mcpTestToken = "mcp-scoped-token" +// mcpTokenExpiresIn is how long the stored test credential's token lives. +var mcpTokenExpiresIn = time.Hour + // storeMCPCredential stores a hey mcp credential granted scope, as `hey auth login // --mcp` would, signed in a week ago. func storeMCPCredential(t *testing.T, baseURL, scope string) { @@ -106,7 +109,7 @@ func storeMCPCredential(t *testing.T, baseURL, scope string) { mgr := auth.NewMCPManager(baseURL, http.DefaultClient, config.ConfigDir(), "LocalMcpClient0000000001") creds := &auth.Credentials{ AccessToken: mcpTestToken, RefreshToken: "mcp-refresh", OAuthType: "oauth", - ExpiresAt: time.Now().Add(time.Hour).Unix(), Scope: scope, ClientID: "LocalMcpClient0000000001", + ExpiresAt: time.Now().Add(mcpTokenExpiresIn).Unix(), Scope: scope, ClientID: "LocalMcpClient0000000001", GrantKey: "test-grant", GrantedAt: time.Now().Add(-7 * 24 * time.Hour).Unix(), } if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { @@ -504,3 +507,53 @@ func TestMCPCommandNeverResendsADeliveryAfterA401(t *testing.T) { t.Errorf("HEY saw %d delivery requests, want 1", got) } } + +// A token that can't be renewed refuses the send without claiming the draft. The reads +// before the claim renew too, so this holds the outcome; the check right before the +// claim is held by mcpserver's TestAGrantNarrowedDuringTheReviewDoesntClaimTheDraft. +func TestMCPCommandDoesntClaimWhenTheTokenCantBeRenewed(t *testing.T) { + var puts, refreshes atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json": + _, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
    Hi Ann
    ","sender":{"id":77,"email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + refreshes.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + case r.Method == http.MethodPut: + puts.Add(1) + _, _ = w.Write([]byte(`{"id":5,"topic_id":900}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + orig := mcpTokenExpiresIn + mcpTokenExpiresIn = -time.Minute // expired: every use asks for a renewal + t.Cleanup(func() { mcpTokenExpiresIn = orig }) + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError || puts.Load() != 0 { + t.Fatalf("sent (%d PUTs) on a token that couldn't be renewed: %v", puts.Load(), result.Content) + } + if strings.Contains(result.Content[0].(*mcp.TextContent).Text, "already sent") { + t.Error("the draft was claimed") + } +} diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go index db9216d48..52402fbac 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -87,6 +87,15 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O // verb. A draft save's answer is this server's own. if d.markExposed != nil && !draft && exposingAnswer(full, resp) { if err := d.markExposed(ctx, full.Action); err != nil { + if !full.ReadOnly { + // HEY already applied the write: say so, so it isn't retried, and + // withhold only the answer. + return gateway.JSONResult(map[string]any{ + "applied": true, + "status": resp.StatusCode, + "note": fmt.Sprintf("HEY applied %s. Its answer is withheld because hey mcp couldn't record that it may include mail from an unscreened sender (%v). Don't retry it.", full.Action, err), + }) + } return gateway.ErrorResult("hey mcp can't return %s right now: it couldn't record that the answer may include mail from an unscreened sender (%v)", full.Action, err), nil } } diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 89edfad2d..4af90345a 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -1370,3 +1370,44 @@ func TestARefusalNamesNoDraftText(t *testing.T) { t.Errorf("the refusal carried the draft's filename: %s", text) } } + +// --- review round nine --- + +func TestACommentIsNeverSent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
    Hi Ann
    ` + }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + if got := rig.drafts.sent()[0].Content; strings.Contains(got, "). + w.comments = append(w.comments, n) return case html.ElementNode: default: @@ -358,12 +369,33 @@ func plainHTML(text string) string { if i > 0 { b.WriteString("
    ") } - b.WriteString(html.EscapeString(line)) + b.WriteString(keepSpaces(html.EscapeString(line))) } b.WriteString("") return b.String() } +// keepSpaces makes the spaces HTML would collapse hold: a leading space, and every +// space after another, become non-breaking, and a tab becomes four, so the recipient +// sees the spacing the confirmation showed. +func keepSpaces(line string) string { + var b strings.Builder + previous := ' ' // a line's first space is leading + for _, r := range line { + switch { + case r == '\t': + b.WriteString("    ") + r = ' ' + case r == ' ' && previous == ' ': + b.WriteString(" ") + default: + b.WriteRune(r) + } + previous = r + } + return b.String() +} + // plainText is the text the prompt shows: the rendering with its line endings // normalized and the one newline its last block ends with dropped, and nothing else changed, // so every space and blank line the body delivers is in what the prompt shows and is From 0c62b58346f45513bc7e13f281e5c1cbd2bd41e3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 23 Oct 2026 18:18:54 -0700 Subject: [PATCH 13/16] Normalize the plain path's text and subject, and check acknowledgements before the marker - On the plain path, body and subject are NFKC-normalized after invisible characters go, so a no-break space and a decomposed letter go as what they look like. The subject sent is the one the confirmation showed. - A write whose success is one exact status (rename_topic) is checked before the exposure marker, so a redirect is never reported as applied. --- go.mod | 2 +- internal/mcpserver/dispatch.go | 10 ++++---- internal/mcpserver/send.go | 6 ++--- internal/mcpserver/send_test.go | 41 ++++++++++++++++++++++++++++++++ internal/mcpserver/sendbody.go | 32 ++++++++++++++++++------- internal/mcpserver/sendprompt.go | 6 ++++- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index efee724bd..c624d8cd2 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 + golang.org/x/text v0.43.0 ) require ( @@ -121,7 +122,6 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go index 52402fbac..4f66c0735 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -81,6 +81,12 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O } return gateway.ErrorResult("%v", err), nil } + // A write whose success is one exact status is checked first, so nothing below + // reports a redirect's 200 as applied. + if want, ok := acknowledgedBy[full.ID]; ok && resp.StatusCode != want { + return gateway.ErrorResult("%s was not acknowledged: HEY answered HTTP %d, not %d, so nothing was changed", + full.Action, resp.StatusCode, want), nil + } // The exposure marker lands before the content leaves (D6). A read not in the // table exposes: it can't be shown not to. So does a write that answers with a body // (reveal_contact answers the contact): what HEY returns is content, whatever the @@ -105,10 +111,6 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O } return gateway.JSONResult(draftResult(resp)) } - if want, ok := acknowledgedBy[full.ID]; ok && resp.StatusCode != want { - return gateway.ErrorResult("%s was not acknowledged: HEY answered HTTP %d, not %d, so nothing was changed", - full.Action, resp.StatusCode, want), nil - } if len(bytes.TrimSpace(resp.Data)) == 0 { result := map[string]any{"status": resp.StatusCode} // A draft save answers 204 with the saved entry's path in Location; diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go index 45752b0bb..56554edc3 100644 --- a/internal/mcpserver/send.go +++ b/internal/mcpserver/send.go @@ -421,12 +421,12 @@ type draftSnapshot struct { // fingerprint hashed, attachments rebuilt from their identity, or, when plain, the // plain text the confirmation showed (sendbody.go). func (d *draftSnapshot) content(plain bool) hey.DraftContent { - body := d.body.delivered + body, subject := d.body.delivered, d.subject if plain { - body = d.body.plainHTML + body, subject = d.body.plainHTML, plainSubject(d.subject) } return hey.DraftContent{ - Subject: d.subject, + Subject: subject, Content: body, To: emails(d.to), CC: emails(d.cc), diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index 4af90345a..c07489ce7 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -1411,3 +1411,44 @@ func TestAWriteHEYAppliedIsAcknowledgedWhenItsMarkerFails(t *testing.T) { t.Error("the withheld answer was returned") } } + +// --- review round ten --- + +// On the plain path, text that looks alike goes alike: a no-break space is a space, +// and a decomposed letter is one, in the body and the subject. +func TestThePlainPathNormalizesLookalikeText(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Subject = "Re:\u00a0Q3 plan, cafe\u0301" + e.Content = "
    Hi\u00a0Ann, cafe\u0301
    " + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + sent := rig.drafts.sent()[0] + if sent.Subject != "Re: Q3 plan, caf\u00e9" { + t.Errorf("subject %q, want it normalized", sent.Subject) + } + if sent.Content != "
    Hi Ann, caf\u00e9
    " { + t.Errorf("body %q, want it normalized", sent.Content) + } +} + +func TestARenameHEYRedirectedIsntReportedAppliedWhenTheMarkerFails(t *testing.T) { + rig := newSendRig(t) + rig.state.fsync = func(*os.File) error { return errors.New("disk full") } + rig.api.resp = &hey.Response{StatusCode: http.StatusOK, Data: json.RawMessage(`{"id":9,"name":"Merged"}`)} + session := rig.connect(t, approve, "", false) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "rename_topic", "params": map[string]any{"topicId": 9, "topic": map[string]any{"name": "Q3"}}}, + }) + if err != nil { + t.Fatal(err) + } + if text := textContent(t, result); !result.IsError || strings.Contains(text, "applied") { + t.Errorf("a rename HEY didn't acknowledge was reported applied: %s", text) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index fbd9da19c..f03817aee 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -13,6 +13,7 @@ import ( "golang.org/x/net/html" "golang.org/x/net/html/atom" + "golang.org/x/text/unicode/norm" ) // What send_draft reads out of a draft's body (sending-safety D2): the text the @@ -344,22 +345,35 @@ func asText(v any) string { return s } -// plainOf is text as it goes when sent as plain text: trailing spaces and tabs dropped -// from each line, and every invisible character removed, so nothing in it is unseen. +// plainOf is text as it goes when sent as plain text: every invisible character +// removed, compatibility-normalized (NFKC, so a no-break space is a space and a +// decomposed letter is one letter, and two spellings that look alike are one), and +// trailing whitespace dropped from each line, so nothing in it is unseen. func plainOf(text string) string { lines := strings.Split(text, "\n") for i, line := range lines { - // Invisible characters go first, so none can shield whitespace from the trim. - lines[i] = strings.TrimRightFunc(strings.Map(func(r rune) rune { - if invisible(r) { - return -1 - } - return r - }, line), unicode.IsSpace) + lines[i] = plainLine(line) } return strings.Join(lines, "\n") } +// plainLine is one line of plainOf. Invisible characters go first, so none can shield +// whitespace from the trim. +func plainLine(line string) string { + visible := strings.Map(func(r rune) rune { + if invisible(r) { + return -1 + } + return r + }, line) + return strings.TrimRightFunc(norm.NFKC.String(visible), unicode.IsSpace) +} + +// plainSubject is a subject as it goes on the plain path: one plain line. +func plainSubject(subject string) string { + return plainLine(strings.ReplaceAll(strings.ReplaceAll(subject, "\r", " "), "\n", " ")) +} + // plainHTML is the body made from plain text: its lines, escaped, with a break between // each, in one block. func plainHTML(text string) string { diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go index b7e935789..ef8a11298 100644 --- a/internal/mcpserver/sendprompt.go +++ b/internal/mcpserver/sendprompt.go @@ -28,7 +28,11 @@ func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string { fmt.Fprintf(&b, " Cc: %s\n", recipientList(snap.cc, outcome.newAddresses)) fmt.Fprintf(&b, " Bcc: %s\n", recipientList(snap.bcc, outcome.newAddresses)) // Delivered text is shown whole: only display names, which aren't sent, are capped. - fmt.Fprintf(&b, " Subject: %s\n", inertWhole(snap.subject)) + subject := snap.subject + if outcome.plain { + subject = plainSubject(subject) + } + fmt.Fprintf(&b, " Subject: %s\n", inertWhole(subject)) fmt.Fprintf(&b, " Attached: %s\n", attachmentList(snap.body.attachments)) b.WriteString(" Body:\n") From 8ca4af5a41248e291b774c565decf8df50a663bb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 27 Oct 2026 20:46:57 -0500 Subject: [PATCH 14/16] Say a Keychain refusal isn't a missing sign-in when hey mcp starts main's #599 classifies a refused Keychain read so the CLI stops asking for another login. hey mcp's own credential check still wrapped it as a plain error, though hey mcp is usually started by an MCP client's background process, the kind the Keychain refuses. --- internal/cmd/mcp_auth.go | 11 +++++++++-- internal/cmd/mcp_auth_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go index 0a265a4ee..7d8f39526 100644 --- a/internal/cmd/mcp_auth.go +++ b/internal/cmd/mcp_auth.go @@ -51,12 +51,19 @@ func errMCPClientUnconfigured() *apierr.Error { // falls back to the CLI's credential: that one holds full, and serving hey mcp on it would // undo what the scoped sign-in contains. func requireMCPCredentials() (*auth.Credentials, error) { - creds, err := mcpAuthMgr.StoredCredentials() + return checkMCPCredentials(mcpAuthMgr.StoredCredentials()) +} + +// checkMCPCredentials is requireMCPCredentials' verdict on what the store returned. A +// store that couldn't be read isn't a missing sign-in: a Keychain refusal says signing in +// again won't help, which matters here since hey mcp is usually started by an MCP +// client's background process, the kind the Keychain refuses. +func checkMCPCredentials(creds *auth.Credentials, err error) (*auth.Credentials, error) { if errors.Is(err, auth.ErrCredentialsNotFound) { return nil, errMCPNotSignedIn() } if err != nil { - return nil, fmt.Errorf("could not read hey mcp's credentials: %w", err) + return nil, auth.CredentialStorageError("could not read hey mcp's credentials", err) } if creds.AccessToken == "" && creds.RefreshToken == "" { return nil, errMCPNotSignedIn() diff --git a/internal/cmd/mcp_auth_test.go b/internal/cmd/mcp_auth_test.go index 752d169c5..e12ce3c06 100644 --- a/internal/cmd/mcp_auth_test.go +++ b/internal/cmd/mcp_auth_test.go @@ -147,3 +147,31 @@ func TestMCPCanSendNeedsWriteAndSend(t *testing.T) { t.Error("a sign-in without a local key can send") } } + +func TestHeyMCPSaysAKeychainRefusalIsntAMissingSignIn(t *testing.T) { + t.Setenv("CODEX_SANDBOX", "") + // What the store returns when macOS's security tool exits 36 for a process outside + // the desktop session, such as an MCP client's background server. + refused := fmt.Errorf("read credentials from system keyring: %w", + errors.Join(errors.New("exit status 36"), auth.ErrKeyringRefused)) + + _, err := checkMCPCredentials(nil, refused) + var classified *apierr.Error + if !errors.As(err, &classified) { + t.Fatalf("err = %v, want a classified error carrying a hint", err) + } + if classified.Code == apierr.CodeAuth { + t.Errorf("code = %q, a refused keyring is not a missing sign-in", classified.Code) + } + if !strings.HasPrefix(classified.Hint, "Signing in again won't help") { + t.Errorf("hint = %q, want it to rule out another hey auth login --mcp", classified.Hint) + } + if !strings.HasPrefix(classified.Message, "could not read hey mcp's credentials: ") { + t.Errorf("message = %q", classified.Message) + } + + _, err = checkMCPCredentials(nil, fmt.Errorf("%w for mcp::https://app.hey.com", auth.ErrCredentialsNotFound)) + if !errors.As(err, &classified) || classified.Code != apierr.CodeAuth || classified.Hint != "Run: "+mcpLoginCommand { + t.Errorf("err = %#v, a missing credential still points at %s", err, mcpLoginCommand) + } +} From ccd17b217f53bdc7240158a0c7da9742168d97f7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 27 Oct 2026 20:58:00 -0500 Subject: [PATCH 15/16] Name no custom element in a send refusal A custom element's name is the draft's own text, and the refusal goes back to the model without recording exposure. Name only elements HTML knows; an attribute is already named by a key from a fixed list. --- internal/mcpserver/send_test.go | 20 ++++++++++++++++++++ internal/mcpserver/sendbody.go | 15 +++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go index c07489ce7..ab65b765f 100644 --- a/internal/mcpserver/send_test.go +++ b/internal/mcpserver/send_test.go @@ -1369,6 +1369,26 @@ func TestARefusalNamesNoDraftText(t *testing.T) { if strings.Contains(text, "IGNORE") { t.Errorf("the refusal carried the draft's filename: %s", text) } + + // A custom element's name is the draft's text too, and a loading attribute on one + // is refused by naming it. + for _, content := range []string{ + `
    Hi Ann
    `, + `
    Hi Annx
    `, + } { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = content }) + session := rig.connect(t, approve, "", false) + text := resultText(t, sendDraft(t, session)) + mustContain(t, text, "loads by itself") + mustContain(t, text, "on a custom element") + if strings.Contains(strings.ToLower(text), "ignore") { + t.Errorf("the refusal carried a custom element's name: %s", text) + } + if len(rig.drafts.sent()) != 0 { + t.Errorf("a draft with a loading custom element was sent") + } + } } // --- review round nine --- diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go index f03817aee..83d9491c2 100644 --- a/internal/mcpserver/sendbody.go +++ b/internal/mcpserver/sendbody.go @@ -192,14 +192,14 @@ func (w *bodyWalker) walk(n *html.Node) { w.lossless = false } if loadingTags[n.DataAtom] && w.remote == "" { - w.remote = "<" + n.Data + ">" + w.remote = elementName(n) } for _, attr := range n.Attr { if n.DataAtom != atom.A || attr.Key != "href" { w.lossless = false } if w.remote == "" && loadsByItself(attr) { - w.remote = attr.Key + " on <" + n.Data + ">" + w.remote = attr.Key + " on " + elementName(n) } } @@ -223,6 +223,17 @@ func (w *bodyWalker) walk(n *html.Node) { } } +// elementName names n for a refusal the model reads. Only an element HTML knows is +// named: a custom element's name is the draft's own text (), and the refusal must carry none of it. An attribute that loads is named by +// its key alone, which loadingAttributes or style has already fixed. +func elementName(n *html.Node) string { + if n.DataAtom == 0 { + return "a custom element" + } + return "<" + n.DataAtom.String() + ">" +} + // loadsByItself reports whether an attribute makes a mail client fetch a URL on open: // a loading attribute, or a style that names one. func loadsByItself(attr html.Attribute) bool { From cf1c83a85f3a86df831c35a84ff31a9ac47d3d3e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 27 Oct 2026 20:58:21 -0500 Subject: [PATCH 16/16] Bump golang.org/x/net to v0.62.0 for GO-2026-6866 and GO-2026-6860 govulncheck finds both reachable through html.Parse and html.Render. --- go.mod | 2 +- go.sum | 4 ++-- nix/package.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 241e59cf1..41026939f 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/yuin/goldmark v1.8.6 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.42.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 diff --git a/go.sum b/go.sum index a7aa903f3..81695e29e 100644 --- a/go.sum +++ b/go.sum @@ -429,8 +429,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= diff --git a/nix/package.nix b/nix/package.nix index 99d5eced4..fe29cf5d6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-UNlf35JRcZFPxRoTTXiY+cdLV+7mjKY6pP14uULfD5U="; + vendorHash = "sha256-wZHV8zifrNPyw1KNE2qIxC38A+z+iD9+QhtRNQqYzvY="; subPackages = [ "cmd/hey" ];