Repository navigation
Give hey mcp its own sign-in, and send a draft only on the person's confirmation - #598
Conversation
|
Revert proof on 3f8aa2f. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 16 of the 16 pieces fail their tests when they are taken out, and
|
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (6)
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved confirmation-integrity, grant-bound exposure, and rolling recipient-limit gaps weaken the sending safeguards.
6 open issues
Reject authentication credential changes after startup · New Measure escaped content before applying the unseen-content limit · New Analyze or reject embedded HTML email bodies · New Display complete recipient addresses without truncation · New Track exposure independently of send-tool registration · New Refresh address timestamps on every claimed send · New
What changed in this PR
This PR gives hey mcp a separate, least-privilege sign-in and adds draft sending through explicit confirmation in the MCP client. Production rollout remains gated on provisioning the local MCP client ID.
Changes:
- Adds isolated MCP credentials, device-flow login with browser fallback, revocation, and status reporting.
- Introduces
hey_sendwith draft-bound confirmation, persistent send claims, recipient limits, and exposure tracking. - Updates command help, documentation, and tests for the new authentication and sending behavior.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
| File | Description |
|---|---|
| README.md | Updates MCP setup and sending guidance. |
| internal/mcpserver/server.go | Registers sending and exposure tracking. |
| internal/mcpserver/sendstate.go | Persists claims, limits, exposure, and confirmation keys. |
| internal/mcpserver/sendprompt.go | Formats send confirmations and escapes fields. |
| internal/mcpserver/sendbody.go | Analyzes bodies, attachments, and remote references. |
| internal/mcpserver/send.go | Implements confirmed draft delivery and policy checks. |
| internal/mcpserver/send_test.go | Tests confirmation, limits, exposure, and durability. |
| internal/mcpserver/exposure.go | Classifies reads for exposure tracking. |
| internal/mcpserver/drafts.go | Updates draft-only messaging to reference confirmed sending. |
| internal/mcpserver/domains.go | Updates the threads tool description. |
| internal/mcpserver/dispatch.go | Records exposure before returning read results. |
| internal/config/mcp.go | Resolves the MCP client ID from trusted configuration. |
| internal/config/mcp_test.go | Tests client ID resolution and validation. |
| internal/cmd/sdk.go | Removes the superseded shared-client helper. |
| internal/cmd/root.go | Initializes MCP authentication separately. |
| internal/cmd/mcp.go | Uses MCP credentials and configures sending. |
| internal/cmd/mcp_test.go | Tests credential isolation, tool availability, and sending. |
| internal/cmd/mcp_auth.go | Implements MCP login, logout, and status handling. |
| internal/cmd/mcp_auth_test.go | Tests MCP authentication commands. |
| internal/cmd/help_topics.go | Documents MCP authentication environment settings. |
| internal/cmd/auth.go | Adds MCP login/logout flags and status output. |
| internal/auth/store.go | Stores scope, client, and grant metadata. |
| internal/auth/oauth.go | Captures granted scopes in token responses. |
| internal/auth/mcp.go | Defines isolated MCP authentication and scope validation. |
| internal/auth/mcp_test.go | Tests scoped login, refresh, fallback, and revocation. |
| internal/auth/device.go | Implements device authorization and polling. |
| internal/auth/auth.go | Supports separate credential keys and scoped refreshes. |
| docs/cli.md | Documents MCP authentication commands. |
| docs/agents.md | Explains MCP sign-in and confirmed sending safeguards. |
| .surface | Records MCP flags on login and logout commands. |
Review effort: Balanced
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f8aa2fc58
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Revert proof on 13c80ff, after round one's fixes. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 23 of the 23 pieces fail their tests when they are taken out, and
|
|
@codex review |
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved sending-safety, delivery-reporting and terminal-output issues need correction before approval.
4 open issues
6 resolved since last review
Track exposure independently of send-tool registration Display complete recipient addresses without truncation Analyze or reject embedded HTML email bodies Measure escaped content before applying the unseen-content limit Reject authentication credential changes after startup Refresh address timestamps on every claimed send
Review effort: Balanced
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 13c80ffa4f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Revert proof on 47dc338, after round two's fixes. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 28 of the 28 pieces fail their tests when they are taken out, and
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47dc338afd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🛡️ Codex Security Review · Automatically triggered
Here are some automated security review suggestions for this pull request.
Reviewed commit: 47dc338afd
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
|
Revert proof on 495d641, after round three. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 29 of the 29 pieces fail their tests when they are taken out, and
|
|
@codex review |
…e 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.
… 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.
…ts 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.
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.
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.
…d pin the client id Sending-safety D4 (recipient caps, the known-recipient rule) and D6 (the exposure window) were withdrawn on 2026-10-31: once a person grants send, how much their agent sends and to whom is theirs to decide. With them go the per-day caps, the 20-recipient cap, new-recipient flags, the exposure table and markers, the plain-text path for new recipients, and the refusals for content the prompt can't show. send_draft now refuses no draft for what it holds; the confirmation shows images and links and names whatever its text leaves out on a "Not shown here" line beside the draft's link in HEY, as the hosted server does. The send claim and confirmation key are per process, as hosted: the state file, its lock and fsyncs existed for the caps and exposure, and HEY's draft lock (haystack#9163) delivers a draft once across processes. hey_send is listed only to a client that can answer an elicitation, and a draft save tells the model to call send_draft as soon as the person asks to send (its confirmation shows what goes out, so don't ask again), or, for a client that can't be asked, names the draft's page in HEY. DefaultMCPClientID is the id haystack#9215 pins for HEY MCP (local) in every environment, so the unconfigured-id path is gone.
cf1c83a to
dd964be
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
All reported issues were addressed across 29 files
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
…text, mark rules, match status to startup, and fix the docs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd992a7e9f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…it renders, and key the credential by canonical origin
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
View guided diff | Turn on auto-fix | Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 172c1bcc0e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6483ca3e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 26 resolved (19 fixed, 7 declined with the reasoning in each thread). CI green and mergeable on b6483ca; no open threads.
|


Ship order:
HEY MCP (local)to client idvd96nBiRFXz1AjTXbADAtkrgin every environment (Oauth::Client::Mcp::LOCAL_MCP_CLIENT_UID).hey mcpgets its own least-privilege sign-in, and it can send, with a confirmation for each email. This is the hey-cli lane of haystack #9041 (thelocal_mcprow) and #9085 (the scoped device flow). It implements sending-safety.md D1–D3 for stdio, as the hosted server does. D4 and D6 were withdrawn on 2026-10-31 and aren't here.Release notes
What changes for a person
hey auth login --mcp # once: "HEY MCP (local)", read write send claude mcp add hey -- hey mcphey mcpexits withRun: hey auth login --mcp. That includes a person signed in to the CLI or withHEY_TOKENset. Existinghey mcpregistrations stop working until the person runs the sign-in once (release notes above). TheheyCLI's commands and its own credential are unchanged.hey_send/send_draftdelivers a draft after the person confirms that exact email in their MCP client. It's listed only to a client that can show a confirmation. Any other client is pointed at the draft's page in HEY.1.
hey auth login --mcphey/hey::mcp::<origin>, beside the CLI'shey::<origin>. With no keychain it gets its own entry incredentials.json. Its refreshes and revocations name the local client id, never the CLI's.HEY_TOKENnever stands in for it.POST /oauth/device_authorizationswithclient_idandscope=read write send, and noresource, per #9085's contract. It falls back to loopback authorization with the same scope and S256 PKCE oninvalid_request(OAUTH_SCOPED_DEVICE_FLOWoff),unauthorized_client(#9041 without #9085) or a 404. Other refusals, such asinvalid_client, fail. The poll handlesauthorization_pending,slow_down(+5 s),access_denied,expired_tokenandinvalid_scope, and sendsinstall_id. The device code is never printed. The link and user code are sanitized before they're shown, and only an http(s) link is opened.scope. A narrower grant is stored as granted: with send unchecked,hey mcpserves nohey_send; without write, reads only. An omittedscopemeans what was requested (RFC 6749 §5.1). A grant naming anything not requested (full, which would mean the id is the CLI's own row) is refused and nothing is stored. An id equal to the CLI's is refused before HEY is contacted.hey auth logout --mcprevokes the grant withDELETE /oauth/tokens(the local client id and the refresh token), then removes the local credential. If the revocation fails, the credential is still removed and a warning says to remove the app in Connected Apps.hey auth statusreports both credentials, in JSON undermcpand styled as anMCP:line. Neither form includes a token.config.DefaultMCPClientIDisvd96nBiRFXz1AjTXbADAtkrg, the id haystack#9215 pins in every environment.HEY_MCP_CLIENT_ID, thenmcp_client_idsin the global config (keyed by origin), override it. A repo's.hey/config.jsoncan't set it.2. No fallback to the CLI credential
hey mcpnever falls back, not even for reads:full.--read-onlyishey mcp's own rule, not HEY's. A read-only fallback still puts a full-scope token in the process the model drives, and the containment that justifies thelocal_mcprow (#9041's threat model) would then rest on hey-cli's gateway filter.3.
send_draft, written in hey-cliWhy it isn't imported. hey-mcp-server's
serverpackage is still underinternal/(hey-mcp-server#14, open), and hey-mcp-server is private while hey-cli is public, so requiring it breaks every outside build. The code is ininternal/mcpserver(send.go,sendbody.go,sendprompt.go) so the W5.1 move can lift it as a unit.How it's wired.
hey_send(D7) hasdestructiveHintandopenWorldHinttrue. It's served only when the credential holdsreadandsend(expanded, sowritecounts asread), the server isn't--read-only, and the threads domain is served.hey_sendfromtools/listfor a client that declares no form elicitation (read from the request's capabilities, so per-request_metaon 2026-07-28 and the session's initialize before it). A call from a cached list still sends nothing and answers with the draft's link (D3).hey_threadsblurb,send_draft's description and each draft save's note say: the person can have a draft shown or revised as they like; when they ask to send, callsend_draftat once, because its confirmation shows what goes out, so don't show the draft again or ask again. A draft save's note depends on whether this client is offeredhey_send: if not, it names the draft'sapp_urlandhey draft send <id>.GetMessageEditand returns an elicitation whose HMAC-signed request state carries the draft's fingerprint, the sign-in, the origin, a 5-minute expiry and a method marker. The second verifies that state and the person'sacceptwithconfirm: true, re-fetches the draft and recomputes the fingerprint; any change fails with "the draft changed since you approved it". It then claims the draft and callsSendDraftonce, through a client that neither retries nor refreshes-and-resends after a 401 (the token is renewed just before the claim instead). An answer with an id and no thread, where the draft's edit page is still served, is reported as not sent, asmail.ConfirmDeliveredreads it. Every delivered field is written from the verified snapshot, and HEY replaces the whole recipient set whenaddressedis present. A 2026-07-28 client carries the rounds; an older one gets server-initiatedelicitation/createfrom the SDK shim. Both are tested."Name" <address>, the subject whole, attachments, images by target, then the body indented, links' targets beside their text, with at most 1,500 characters shown and the header saying how many there are. Controls, line separators and every invisible character are escaped as\u{…}. Whatever the text can't show (the rest of the body, what attachments hold, images, markup outside a text-and-links allowlist) is named on aNot shown here:line beside the draft's link in HEY. That line names only elements and attributes HTML knows, never a custom element's or attribute's name, which is the draft's own text.send_atisn't implemented), one with no recipients. Nothing a draft holds refuses it (sending-safety status, 2026-10-31).Single use and never retried. The claim is per process, keyed by draft id, lasts 24 hours and is never released, even when HEY refuses. A replayed state, a second confirmation of the same draft and a resend after a failure are all refused with
SendDraftcalled once, and a draft this process sent isn't put to the person again. Across processes, haystack #9163's draft lock delivers a draft once, which is why the hosted server's claim is per process too. The confirmation key is per process: both rounds reach the samehey mcp.A process stays on the sign-in it started with. Its auth manager is pinned to that sign-in's key, so the tools it listed match the grant its token carries, and a later
hey auth login --mcpis refused rather than adopted until restart. A renewal that narrows the grant below sending refuses the send before the claim.Help and docs.
hey mcp --help,hey auth login/logout --help,docs/agents.md("Its own sign-in", "Drafts first, sends confirmed"),docs/cli.md, the environment help topic (HEY_MCP_CLIENT_ID), and the README's MCP line..surfacegains--mcpon login and logout.skills/hey/SKILL.md(the CLI's draft, review, send path from #615) is unchanged: the CLI has no server-side confirmation, so its in-chat review stays.Tests
make checkis green (fmt-check, vet, golangci-lint, unit tests, sync-skills, tidy-check, surface, release lockstep).TestMCPLoginStoresACredentialApartFromTheCLIs,…InThePlaintextFallbackToo,TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBothhey mcprefuses with no MCP credentialTestMCPCommandRefusesWithoutItsOwnCredential(CLI signed in andHEY_TOKENset; nothing reaches HEY),TestMCPManagerNeverUsesHEYTokenTestMCPLoginHonorsTheScopeHEYGranted,TestMCPLoginRefusesAScopeItDidntAskFor,TestMCPCommandServesWhatTheGrantAllows(with a client that can be asked, sohey_send's absence is the grant's doing),TestMCPRefreshNamesTheLocalClientAndKeepsTheScopesend_draftneeds a confirmed elicitationTestSendDraftSendsOnlyWhatThePersonConfirmed(both eras),TestSendDraftWithoutApprovalSendsNothingTestHeySendIsListedOnlyToClientsThatCanAsk(both eras; fails with the filter disabled),TestADraftSaveSaysHowThisClientSendsIt,TestSendDraftCantSendFromAHostWithoutElicitation(a cached list)TestSendDraftRefusesADraftChangedAfterApproval,TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange,TestAReplayedConfirmationSendsNothing,TestTwoConfirmationsOfOneDraftSendItOnce,TestSendDraftSendsADraftOnce,TestASentDraftIsntAskedAboutAgain,TestAForgedOrExpiredConfirmationSendsNothingTestNothingADraftHoldsRefusesIt(image, style, stylesheet, hidden text, non-blob and blob attachments),TestTheConfirmationShowsEveryImageAndLink,TestALongBodyIsShownInPartAndSaysHowMuch,TestThePromptsOwnLinesNameNoDraftText,TestAStylesheetsTextIsntShownAsTheBodyAlso: inert rendering, whole subject and address, never retried and stays claimed, HEY keeping the draft, a sign-in changed or narrowed under the server, attachments delivered as their identity, comments never sent, and an end-to-end run through
hey mcp(TestMCPCommandSendsAConfirmedDraftOnce,TestMCPCommandNeverResendsADeliveryAfterA401).Not verified against a live HEY
GetMessageEditfetches. If it isn't stable, a draft with an attachment always fails as "changed", which fails closed. The same holds for an attachment that isn't a plain blob, which goes as it is and is hashed raw.UpdateMessageaccepts the rebuilt, childless attachment figure as Trix's own submission.Still to do elsewhere
OAUTH_SCOPED_DEVICE_FLOWper host (until then the login falls back to loopback).hey mcpimports them. Its stdio gateway refuses clearance writes other than screen-out (D5); hey-cli's still serves them, out of scope here.UpdateMessageidempotent with retries (D2.4);send_draftgoes through hey-cli's no-retry client, as everyhey mcpmutation does.