@rndmjoker/proton-mcp 0.4.13 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -21
- package/dist/browser/channel.d.ts +76 -0
- package/dist/browser/channel.js +162 -0
- package/dist/browser/channel.js.map +1 -0
- package/dist/browser/client.d.ts +50 -0
- package/dist/browser/client.js +284 -0
- package/dist/browser/client.js.map +1 -0
- package/dist/browser/connector.d.ts +15 -0
- package/dist/browser/connector.js +26 -0
- package/dist/browser/connector.js.map +1 -0
- package/dist/browser/host.d.ts +50 -0
- package/dist/browser/host.js +258 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/messages.d.ts +163 -0
- package/dist/browser/messages.js +338 -0
- package/dist/browser/messages.js.map +1 -0
- package/dist/browser/paths.d.ts +51 -0
- package/dist/browser/paths.js +92 -0
- package/dist/browser/paths.js.map +1 -0
- package/dist/browser/setup.d.ts +42 -0
- package/dist/browser/setup.js +170 -0
- package/dist/browser/setup.js.map +1 -0
- package/dist/filters/order.d.ts +66 -0
- package/dist/filters/order.js +108 -0
- package/dist/filters/order.js.map +1 -0
- package/dist/filters/sieve.d.ts +184 -0
- package/dist/filters/sieve.js +1312 -0
- package/dist/filters/sieve.js.map +1 -0
- package/dist/instances.d.ts +58 -0
- package/dist/instances.js +145 -0
- package/dist/instances.js.map +1 -0
- package/dist/mail/compose.d.ts +10 -0
- package/dist/mail/compose.js.map +1 -1
- package/dist/mail/record.d.ts +36 -0
- package/dist/mail/record.js +74 -0
- package/dist/mail/record.js.map +1 -0
- package/dist/permissions.d.ts +139 -0
- package/dist/permissions.js +355 -0
- package/dist/permissions.js.map +1 -0
- package/dist/server.js +330 -16
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +12 -0
- package/dist/session.js +23 -0
- package/dist/session.js.map +1 -1
- package/dist/settings.d.ts +81 -11
- package/dist/settings.js +160 -25
- package/dist/settings.js.map +1 -1
- package/dist/tools/confirm.d.ts +4 -2
- package/dist/tools/confirm.js +12 -3
- package/dist/tools/confirm.js.map +1 -1
- package/dist/tools/failures.d.ts +6 -8
- package/dist/tools/failures.js +36 -17
- package/dist/tools/failures.js.map +1 -1
- package/dist/tools/filter-tools.d.ts +82 -0
- package/dist/tools/filter-tools.js +729 -0
- package/dist/tools/filter-tools.js.map +1 -0
- package/dist/tools/filters.d.ts +8 -1
- package/dist/tools/filters.js +24 -12
- package/dist/tools/filters.js.map +1 -1
- package/dist/tools/format.d.ts +15 -0
- package/dist/tools/format.js +4 -3
- package/dist/tools/format.js.map +1 -1
- package/dist/tools/guidance.js +12 -7
- package/dist/tools/guidance.js.map +1 -1
- package/dist/tools/interface.d.ts +20 -6
- package/dist/tools/interface.js +40 -16
- package/dist/tools/interface.js.map +1 -1
- package/dist/tools/send.d.ts +9 -0
- package/dist/tools/send.js +51 -9
- package/dist/tools/send.js.map +1 -1
- package/dist/tools/sign-in-required.d.ts +15 -2
- package/dist/tools/sign-in-required.js +30 -2
- package/dist/tools/sign-in-required.js.map +1 -1
- package/dist/web/layout.d.ts +12 -5
- package/dist/web/layout.js +32 -9
- package/dist/web/layout.js.map +1 -1
- package/dist/web/pages.d.ts +4 -2
- package/dist/web/pages.js +10 -9
- package/dist/web/pages.js.map +1 -1
- package/dist/web/pending.d.ts +9 -6
- package/dist/web/pending.js +9 -7
- package/dist/web/pending.js.map +1 -1
- package/dist/web/preview-server.d.ts +33 -0
- package/dist/web/preview-server.js +102 -0
- package/dist/web/preview-server.js.map +1 -0
- package/dist/web/sections.d.ts +86 -1
- package/dist/web/sections.js +290 -7
- package/dist/web/sections.js.map +1 -1
- package/dist/web/security.d.ts +48 -13
- package/dist/web/security.js +68 -16
- package/dist/web/security.js.map +1 -1
- package/dist/web/server.d.ts +81 -24
- package/dist/web/server.js +478 -117
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ An MCP server for Proton Mail. It talks to a locally running Proton Mail Bridge
|
|
|
6
6
|
>
|
|
7
7
|
> **Largely AI-generated.** Most of the code, tests and documentation in this repository were written by Anthropic's Claude Opus models. I decide what gets built and how, and I note this on every one of my projects that is largely AI-generated.
|
|
8
8
|
|
|
9
|
-
**Status: under construction.** Reading works and can be used, and so does signing in through the browser. Labels, moving between folders, read state, trash, drafts and sending all work.
|
|
9
|
+
**Status: under construction.** Reading works and can be used, and so does signing in through the browser. Labels, moving between folders, read state, trash, drafts and sending all work. By default every send asks you first, and that question is enforced by the server rather than requested in a prompt; you can allow a tool to act on its own, within bounds you choose. See [Status](#status) for the details.
|
|
10
10
|
|
|
11
11
|
## Why everything runs locally
|
|
12
12
|
|
|
@@ -37,13 +37,20 @@ This server therefore runs on your machine as well, started by the AI client as
|
|
|
37
37
|
| `reply_draft` | Prepares a reply as a draft, with the original quoted |
|
|
38
38
|
| `forward_draft` | Prepares a forward as a draft, attachments and all |
|
|
39
39
|
| `list_drafts` | The drafts, newest first |
|
|
40
|
-
| `send_message` | Composes and sends. Asks you first,
|
|
40
|
+
| `send_message` | Composes and sends. Asks you first unless you chose otherwise, see [What the assistant may do](#what-the-assistant-may-do) |
|
|
41
41
|
| `send_reply` | Replies and sends, keeping the conversation intact |
|
|
42
42
|
| `send_forward` | Forwards and sends, attachments and all |
|
|
43
43
|
| `send_draft` | Sends a draft that is already written, exactly as it was stored |
|
|
44
|
-
| `open_configuration` | Hands back the address of the local web interface,
|
|
44
|
+
| `open_configuration` | Hands back the address of the local web interface, the same for every running server |
|
|
45
45
|
| `check_for_updates` | Asks npm whether a newer version exists. The only thing here that contacts anything but your own machine, and only when called |
|
|
46
|
-
| `
|
|
46
|
+
| `list_filters` | The names of your Proton Mail filters in the order Proton runs them, read through the browser extension [Proton MCP Helper](#the-browser-extension-for-filters). Also what waits in the extension and its last changes |
|
|
47
|
+
| `get_filters` | One or several filters in full: what each does to matching mail and its whole Sieve script |
|
|
48
|
+
| `propose_filter` | A new filter, or a new version of one, as a Sieve script. You agree in a window of the extension, which shows the whole script and, for a change, what changes |
|
|
49
|
+
| `apply_filter` | Runs a saved filter over the mail already there, after you agree in the same kind of window. On a large mailbox Proton takes hours for that |
|
|
50
|
+
| `reorder_filters` | Moves filters in the order Proton runs them, after you agree to the new order |
|
|
51
|
+
| `delete_filter` | Deletes a filter for good, after you agree. Off until you switch it on. The answer keeps the whole script, so the filter can be set up again |
|
|
52
|
+
| `set_filter_status` | Switches a filter on or off, after you agree. Off until you switch it on |
|
|
53
|
+
| `manage_filters` | Reads and changes nothing. Explains the way through Proton's own settings page, for when the extension is not there |
|
|
47
54
|
|
|
48
55
|
Every tool that composes a message takes either `text` or `html`, never both: Proton drops the plain text half of a message that carries markup, so the half you confirmed would be the half that never arrived.
|
|
49
56
|
|
|
@@ -53,17 +60,18 @@ The server also tells an assistant how it works, so the rules are not something
|
|
|
53
60
|
|
|
54
61
|
Underneath: a held IMAP connection that recovers from a Bridge restart, stable identifiers based on the Message-ID, HTML to text conversion, filtering of the public key Proton attaches to every sent message, and a character budget so a single message cannot exhaust a context window.
|
|
55
62
|
|
|
56
|
-
There is also a **local web interface**, on `127.0.0.1` only,
|
|
63
|
+
There is also a **local web interface**, on `127.0.0.1` only, at one fixed address: `http://127.0.0.1:7345/` unless `PROTON_MCP_WEB_PORT` says otherwise. Every assistant session starts its own server, and all of them share this one interface and its settings: the first one to start serves it, and when that one stops, the next takes over. Right after an update, a server from before 0.5.0 may still run in a session started earlier and hold that address with an interface of its own; the shared one appears as soon as that session ends, and at once if you restart it. It is divided into sections, reached from a rail on the left:
|
|
57
64
|
|
|
58
65
|
| Section | What is there |
|
|
59
66
|
| :--- | :--- |
|
|
60
|
-
| Overview | Connection, address, where the password is kept, read-only mode, both Bridge ports, uptime and counters. State only: every control lives in the section it acts on |
|
|
67
|
+
| Overview | Connection, address, where the password is kept, read-only mode, both Bridge ports, whether changes need a PIN, uptime and counters, and every running server with its sign-in. State only: every control lives in the section it acts on |
|
|
61
68
|
| Folders and labels | On its own page, only when asked. They belong to the whole account, so a folder named after a bank gives that away on its own |
|
|
62
69
|
| Bridge | The ports, the pinned certificate, and a button that tries the connection. Ports are stored in `~/.config/proton-mcp/settings.json`; an explicit environment variable wins and the form disappears |
|
|
63
|
-
| Credentials | Where the Bridge password is kept, and signing out, which clears it from every store at once |
|
|
70
|
+
| Credentials | Where the Bridge password is kept, and signing out, which clears it from every store at once and signs out every running server |
|
|
71
|
+
| Permissions | What each tool may do, the bounds on sending without asking, a copy of everything sent, and the PIN that every change in this interface asks for once you set one |
|
|
64
72
|
| Activity | What is running and for how long, plus the last 50 finished calls. Tool names and timings only, never arguments |
|
|
65
73
|
|
|
66
|
-
One more appears only while a message is waiting for you to confirm it, linked from that question: the message rendered as the recipient will see it, every address in full, every alt text, every attachment. It is the one place
|
|
74
|
+
One more page appears only while a message is waiting for you to confirm it, linked from that question: the message rendered as the recipient will see it, every address in full, every alt text, every attachment. It is the one place that shows mail, and it carries no confirm button by design. It is served by the server that asked, on a port of its own, because the message is held in that server's memory and nowhere else.
|
|
67
75
|
|
|
68
76
|
Before signing in there are two more: the sign-in form, where the address and Bridge password go into the browser rather than into a client configuration or a conversation, with four places to keep the password and the cost of each stated; and the unlock page, which after a restart asks for the master password alone, since the Bridge password is already on disk.
|
|
69
77
|
|
|
@@ -78,9 +86,7 @@ This table says what the server cannot do today. The issue behind each entry say
|
|
|
78
86
|
| Missing | Tracked in |
|
|
79
87
|
| :--- | :--- |
|
|
80
88
|
| Attachments composed from files on this machine | [#17](https://github.com/RndmJoker/proton-mcp/issues/17) |
|
|
81
|
-
| Choosing per tool what an assistant may do | [#19](https://github.com/RndmJoker/proton-mcp/issues/19) |
|
|
82
89
|
| Choosing in the interface how much markup a message may carry. The levels exist and a caller picks one per message; what is missing is a ceiling the caller cannot raise | [#24](https://github.com/RndmJoker/proton-mcp/issues/24) |
|
|
83
|
-
| A fixed extra recipient the assistant cannot remove | [#18](https://github.com/RndmJoker/proton-mcp/issues/18) |
|
|
84
90
|
|
|
85
91
|
**Binary attachments** are not returned as text: base64 in the context window would be unusable. `save_attachment` writes one to a directory you name instead, and it is off until you name one, see [Saved attachments](#saved-attachments).
|
|
86
92
|
|
|
@@ -90,7 +96,7 @@ These limits come from the Bridge itself and cannot be worked around:
|
|
|
90
96
|
|
|
91
97
|
- **No calendar, no contacts.** Neither flows through the Bridge.
|
|
92
98
|
- **No Proton-specific features.** No message expiration, no password-protected messages to outside recipients.
|
|
93
|
-
- **No
|
|
99
|
+
- **No forwarding rules and no account settings.** This is also a deliberate security boundary, see below. Filters are within reach through the [browser extension](#filters-through-the-browser-extension), and what a filter may do beyond filing and flags, and deleting or switching one, is yours to allow. Forwarding stays out: Proton does not run it in filters, and its own forwarding setting is out of reach of these tools.
|
|
94
100
|
- **No server-side threading or sorting.** The Bridge supports neither `THREAD` nor `SORT`, so ordering happens in this server, which costs one date read per message before a page can be cut out.
|
|
95
101
|
- **No permanent deletion.** Deleted will mean moved to trash, on purpose.
|
|
96
102
|
|
|
@@ -103,6 +109,8 @@ These limits come from the Bridge itself and cannot be worked around:
|
|
|
103
109
|
|
|
104
110
|
## Installing
|
|
105
111
|
|
|
112
|
+
Filters additionally need the browser extension, which for now is installed by hand, see [The browser extension for filters](#the-browser-extension-for-filters).
|
|
113
|
+
|
|
106
114
|
### Hand it to your assistant
|
|
107
115
|
|
|
108
116
|
If you already have an AI assistant with a shell, the shortest route is to let it do the work:
|
|
@@ -121,7 +129,7 @@ Or hand over the guide, the same way as for the installation:
|
|
|
121
129
|
Fetch https://raw.githubusercontent.com/RndmJoker/proton-mcp/main/update.md and follow it
|
|
122
130
|
```
|
|
123
131
|
|
|
124
|
-
It works out how you installed it, since the three ways look identical from the outside and are updated differently, and it reminds you that the client has to be restarted. Nothing has to be set up again: credentials, the pinned certificate and the ports all survive.
|
|
132
|
+
It works out how you installed it, since the three ways look identical from the outside and are updated differently, and it reminds you that the client has to be restarted. Nothing has to be set up again: credentials, the pinned certificate and the ports all survive. The browser extension, where you installed it, is the exception and is replaced by hand, see [Updating it](#the-browser-extension-for-filters).
|
|
125
133
|
|
|
126
134
|
### From npm
|
|
127
135
|
|
|
@@ -211,6 +219,8 @@ If you pick the encrypted file, every later start shows an unlock page asking fo
|
|
|
211
219
|
|
|
212
220
|
**Signing in again replaces what was stored.** The password then lives only where you chose last, and every other place is emptied. **Signing out empties all four.** If one of them cannot be emptied, a file the server may not delete for instance, the page says which and how to remove it by hand, because until then the next start signs in with it again.
|
|
213
221
|
|
|
222
|
+
**With several sessions running**, a password kept in the system keyring or in a file reaches every one of their servers, which reread it whenever you sign in or out. One kept for this session only, or in an encrypted file opened in the interface, stays with the server that shows the interface, because it lives in that server's memory alone. The overview lists every running server with its sign-in, so this is visible rather than puzzling. A server left out says so when a tool needs the sign-in, and names the two ways out: sign in again with the password kept where every server reads it, or end the session whose server shows the interface, so that the next one shows it and asks for what it needs.
|
|
223
|
+
|
|
214
224
|
#### Without a browser
|
|
215
225
|
|
|
216
226
|
Environment variables still work and take precedence over anything stored:
|
|
@@ -265,13 +275,44 @@ The `sleep` keeps the input open while the answer is on its way. A client signal
|
|
|
265
275
|
|
|
266
276
|
You should see your system mailboxes. If instead you get a message saying the Bridge is probably not running, start and unlock the Bridge and try again. The server explains what to do in its error messages rather than printing error codes.
|
|
267
277
|
|
|
278
|
+
### The browser extension for filters
|
|
279
|
+
|
|
280
|
+
Optional. Proton keeps filters behind an interface it has not opened to other programs, and the Bridge does not carry them. The extension **Proton MCP Helper** does the work inside the Proton page you are signed in to in Chrome, and a small connector program, which Chrome starts for it, carries the requests between this server and the extension. This server never holds a Proton session or password for it.
|
|
281
|
+
|
|
282
|
+
**For now it is installed by hand.** It is not in the Chrome Web Store. Every [release](https://github.com/RndmJoker/proton-mcp/releases) carries it as `proton-mcp-helper-<version>-chrome.zip`. Take the one of the release that matches your server, which for `npx -y @rndmjoker/proton-mcp@latest` is the latest.
|
|
283
|
+
|
|
284
|
+
1. Set up the connector, once per computer:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
npx -y @rndmjoker/proton-mcp@latest setup-browser
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
It writes a secret only you can read, a copy of the connector and a launcher under `~/.config/proton-mcp/browser/`, and the file that tells Chrome which program the extension may start. It prints what it wrote. Run it again after Node moved, for example to another version under another path.
|
|
291
|
+
2. Download the zip from the release and unpack it into a folder of its own, somewhere it can stay. Chrome loads the extension from that folder every time it starts, so moving or deleting the folder removes the extension.
|
|
292
|
+
3. Open `chrome://extensions`, switch on Developer mode, choose **Load unpacked** and pick that folder. Its card has to show the id `nfmconelbflppeoolebhcckbmffiokkd`, the only one the connector lets in.
|
|
293
|
+
4. Reload any open Proton tab once, or open `https://mail.proton.me` and sign in.
|
|
294
|
+
|
|
295
|
+
The extension's popup says whether the connector runs and what waits; when it does not run, it says why. It also holds the extension's own switches for saving, applying, moving, deleting and switching filters without its window, and what a filter may do beyond filing and flags, all off by default, see [Filters, through the browser extension](#filters-through-the-browser-extension). When the extension cannot be reached, the filter tools say so as well, with the cause where it can be told: no setup, Chrome not running, the extension missing or switched off. To take the connector away again, run the same command with `--remove`, and remove the extension in `chrome://extensions`.
|
|
296
|
+
|
|
297
|
+
**Updating it.** Chrome never updates an extension loaded by hand, and the connector stays the copy `setup-browser` made. So after the server was updated:
|
|
298
|
+
|
|
299
|
+
1. Run `setup-browser` again, as above. The secret stays, the copy of the connector is replaced.
|
|
300
|
+
2. Download the zip of the new release, empty the folder Chrome loads the extension from, and unpack the zip into it.
|
|
301
|
+
3. In `chrome://extensions`, click the reload arrow on the extension's card, then reload the Proton tab.
|
|
302
|
+
|
|
303
|
+
When the connector no longer fits the server, the filter tools say so, with these steps.
|
|
304
|
+
|
|
305
|
+
**The code.** The source of the extension is in [`extension/`](extension/) in this repository, and its [readme](extension/README.md) says how to build it yourself. The zip attached to a release is built from that release's tagged commit, and GitHub attaches the source of the whole repository to every release as well.
|
|
306
|
+
|
|
307
|
+
Chrome first. Measured on Linux; the setup for macOS and Windows is written but not measured yet. One Chrome profile at a time can serve the server.
|
|
308
|
+
|
|
268
309
|
## Security
|
|
269
310
|
|
|
270
311
|
The content of someone else's email is text written by strangers, and it is handed to a language model that can call tools. A crafted message may try to instruct the assistant. This server assumes that will happen:
|
|
271
312
|
|
|
272
313
|
- **Message content is marked as untrusted** in every answer, enclosed in markers and separated from anything the server itself says. The markers carry a label drawn per answer, because a fixed one is written down in this repository: a sender could put the closing marker in the body and have everything after it read as though the server wrote it. A label that changes every time cannot be copied. For the same reason a subject, a display name or an attachment name never breaks across lines, however many line breaks it was sent with; the break is shown, not removed.
|
|
273
314
|
- **Nothing is filtered out of the content** and no attempt is made to detect attacks. Such filters pretend to offer safety they cannot deliver.
|
|
274
|
-
- **Sending
|
|
315
|
+
- **Sending requires your confirmation by default**, enforced in the server rather than in a system prompt, and a client that cannot ask the question cannot send. You can allow a sending tool to act on its own; the switch is guarded by the PIN when you set one, the environment can fix it either way, and bounds can limit who it may send to, how often and until when.
|
|
275
316
|
- **There will be no permanent deletion**, and no tool that reads or changes filters, forwarding, account or key settings. A hijacked session must not be able to leave behind lasting access.
|
|
276
317
|
- **HTML is never passed through raw** and external content is never fetched. Tracking pixels stay ineffective.
|
|
277
318
|
- **Your mail is not kept on your disk.** No cache, no index, no copies. Anything this server wrote down would be your mail in plain text, outside the encryption you pay Proton for. The one exception is an attachment you have the assistant save, into a directory you chose, for an hour at most, see [Saved attachments](#saved-attachments).
|
|
@@ -307,16 +348,16 @@ One more thing worth knowing about drafts: Proton rewrites the thread headers of
|
|
|
307
348
|
|
|
308
349
|
Writes need a moment to settle. A move takes roughly fifteen seconds to reconcile with Proton, and until then the Bridge lists the message in both places, so the tools say so rather than reading back an intermediate state and calling it the result. Flags are the exception: those hold immediately.
|
|
309
350
|
|
|
310
|
-
### Sending, and the question
|
|
351
|
+
### Sending, and the question before it
|
|
311
352
|
|
|
312
353
|
Mail is text written by strangers, handed to a model that can call tools. A message can politely ask to be forwarded somewhere, and other projects answer that with a line in a system prompt. **A system prompt is not a security boundary.** This one lives in the server:
|
|
313
354
|
|
|
314
|
-
- **
|
|
315
|
-
- **The question is short, and the message is somewhere with room.** It carries no part of the message itself; it links to a page
|
|
355
|
+
- **By default, nothing is sent without a person saying yes.** The first call never sends. It returns a question through your client naming the sender, every recipient separated into To, Cc and Bcc, and the subject. Only the second call, carrying your answer, sends.
|
|
356
|
+
- **The question is short, and the message is somewhere with room.** It carries no part of the message itself; it links to a page, served by the server that asks, where you can read it as the recipient will see it, with every address in full. That split exists because the question used to grow with the message: 74 lines for a newsletter-shaped one, at which point the confirm button sat below the bottom of the dialog and nothing could be sent at all.
|
|
316
357
|
- **Recipients are never summarised**, however many there are. Seeing three of eight would mean agreeing to a send to five strangers.
|
|
317
358
|
- **The address you confirm is the address the message goes to.** Every recipient is read back through the code that builds the envelope, and refused if it would come out as another address or as none. An address with a character outside ASCII is refused as well: letters from another script can spell a familiar domain, and the message would go to a different one that anyone can register. A domain that really is written with such letters can be given in its `xn--` form, which is then exactly what you confirm.
|
|
318
|
-
- **The answer goes through your client, never through the browser.** That page has no confirm button and will not get one:
|
|
319
|
-
- **
|
|
359
|
+
- **The answer goes through your client, never through the browser.** That page has no confirm button and will not get one: its address travels with the question and so reaches your assistant, so a control there could be operated by the thing it is supervising.
|
|
360
|
+
- **You can switch the question off, per tool, and nobody else can when a PIN is set.** See [What the assistant may do](#what-the-assistant-may-do) below. Off is the default, and an answer the server already asked for always stands: a "no" given after the level changed is still a no.
|
|
320
361
|
- **A client that cannot ask cannot send.** If your client does not support form elicitation, sending is refused and you are pointed at drafts instead. That is deliberately different from the sign-in prompt, which falls back to text: the worst outcome there is an inconvenience, and here it is a message that cannot be recalled. What counts as "can ask" is the capability your client declares, read from the request when the protocol revision carries it there and from the handshake otherwise.
|
|
321
362
|
- **Your yes covers one specific message.** A fingerprint of the sender, every recipient, the subject and the body is sealed with an HMAC whose key exists only in the running process, and it travels with the question. On the way back the fingerprint is recomputed from what is being asked for now. If a single address was added in between, nothing is sent. A confirmation is also bound to the tool that asked, so one cannot be reused for another.
|
|
322
363
|
- **Blind copies stay blind.** They travel in the envelope and never appear in the headers the recipients see. Verified against a real delivery.
|
|
@@ -326,6 +367,68 @@ Replying and forwarding exist as sending tools of their own, not just as drafts.
|
|
|
326
367
|
|
|
327
368
|
After a send, nothing checks its own work. The Bridge took about six and a half seconds to accept a message and the message appeared over IMAP about three seconds after that, so an immediate look would describe a state that is not the outcome.
|
|
328
369
|
|
|
370
|
+
### What the assistant may do
|
|
371
|
+
|
|
372
|
+
Every tool that can be switched has a level, chosen on the Permissions page of the interface or in the environment, which wins:
|
|
373
|
+
|
|
374
|
+
| Level | What the tool does |
|
|
375
|
+
| :--- | :--- |
|
|
376
|
+
| `off` | Is taken away from the assistant entirely. It disappears from the list your client sees, and a call that arrives anyway is refused |
|
|
377
|
+
| `ask first` | Runs only after you agree: the sending tools ask through your client, the filter tools that change something in a window of the browser extension. These do it by default, and only these can |
|
|
378
|
+
| `act on its own` | Runs without asking. Every other tool does this by default |
|
|
379
|
+
|
|
380
|
+
**Nothing changes on an upgrade:** the defaults are the behaviour before levels existed, and `delete_filter` and `set_filter_status`, which came later, are off until you switch them on. `open_configuration` and `check_for_updates` are always there and have no level.
|
|
381
|
+
|
|
382
|
+
In the environment, three lists take tool names or the groups `reading`, `filing`, `writing`, `sending` and `filters`. A tool named on its own wins over its group, and anything contradictory or unknown stops the server at start rather than letting it run with permissions nobody chose:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
PROTON_MCP_OFF=trash_messages,send_forward
|
|
386
|
+
PROTON_MCP_ASK_FIRST=send_draft
|
|
387
|
+
PROTON_MCP_ACT_ON_ITS_OWN=send_reply
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`PROTON_MCP_READ_ONLY=true` still works and switches every tool that changes something off, whatever else says otherwise.
|
|
391
|
+
|
|
392
|
+
**Sending without asking costs something, and the Permissions page says so where you switch it on:** your private mail, text written by strangers and a way out are then together with no person in between. A single message can ask for your mailbox to be forwarded, and nobody would see it happen. Three bounds make that smaller, each one optional, set on the same page or in the environment:
|
|
393
|
+
|
|
394
|
+
| Bound | Environment | What happens outside it |
|
|
395
|
+
| :--- | :--- | :--- |
|
|
396
|
+
| Permitted recipients, full addresses or domains written as `@example.com` | `PROTON_MCP_SEND_ALLOWED` | The send asks first |
|
|
397
|
+
| A maximum per hour, counted per running server | `PROTON_MCP_SEND_MAX_PER_HOUR` | The send asks first |
|
|
398
|
+
| A last day, written YYYY-MM-DD | `PROTON_MCP_ACT_UNTIL` | Every send asks first again |
|
|
399
|
+
|
|
400
|
+
A send outside a bound becomes a question, not a failure, and the question says which bound held it back. A send that went out without asking says so in its answer, and the overview of the interface lists every tool whose level departs from the default. **Without a PIN, anything on your machine can change these levels, the assistant included**, so set one before you allow sending without asking, or set the levels in the environment.
|
|
401
|
+
|
|
402
|
+
### A copy of everything sent
|
|
403
|
+
|
|
404
|
+
One address can receive a copy of every message any sending tool sends: as a blind copy, for a record the recipients do not see, or as Cc, for a second person who sees every send. Set it on the Permissions page or in the environment, which wins:
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
PROTON_MCP_COPY_TO=archive@example.com
|
|
408
|
+
PROTON_MCP_COPY_AS=bcc
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
- **It is added before the question is asked**, so it is part of what you confirm, and the question names it as coming from this setting rather than from the assistant.
|
|
412
|
+
- **Nothing the assistant passes removes it.** An address the assistant named itself is not added a second time.
|
|
413
|
+
- **A send without asking carries it too**, and it does not count against the permitted recipients.
|
|
414
|
+
- **Only an address in plain ASCII is accepted**, for the same reason as every other recipient, and more so: this one receives everything.
|
|
415
|
+
|
|
416
|
+
A field that quietly adds a recipient to every message is what an attacker would most like to set. So the overview shows it whenever it is on, and once a PIN is set, only someone who knows the PIN can change it.
|
|
417
|
+
|
|
418
|
+
### Filters, through the browser extension
|
|
419
|
+
|
|
420
|
+
What an assistant can do with filters, and what keeps that in bounds. Each point is held in this server and again in the extension; neither trusts the other's check.
|
|
421
|
+
|
|
422
|
+
- **Filing and flags, and anything more only where you allow it.** A proposed script has to start with Proton's spam guard, exactly as Proton writes it, and may file into folders and labels and set or clear the read and star flags. Beyond that, each of these is refused until you allow it, on the Permissions page and again in the popup of the extension: moving mail to Trash or Spam, `discard`, `expire`, `reject`, `vacation` and `stop`. Allowed, the window names what it does and what it costs, and a text that goes to a sender has to be written out, with no variable in it. Deleting a filter and switching one on or off are tools of their own, off until you switch them on. `redirect` is refused whatever you allow: Proton does not run it in filters.
|
|
423
|
+
- **Nothing that reads differently from what it does**, whatever you allow: comments other than Proton's own two, the line above the spam guard and the one its filter editor writes above each rule, `text:` blocks and line breaks inside a string, invisible characters, `:regex` and Proton's own `eval` extension, a folder given as a bare number or named by a variable, and anything this check does not know.
|
|
424
|
+
- **You agree in a window of the extension**, by default, before anything is saved, applied, moved, deleted or switched. It shows the account, what the filter does to matching mail, the whole script, and for a change the difference to the stored version; for a move, the new order and a warning where a filter that does something an assistant may not set up changes place. Each window answers only in the tab it was opened in, and only once. Its buttons are locked for a moment and a half whenever it opens or comes to the front, and the one that refuses comes first, so a click meant for another window or a stray Enter does not agree.
|
|
425
|
+
- **Acting without the window takes two switches, and so does every allowed action.** The tool's level on the Permissions page, like the sending tools, and the extension's own switch for that action in its popup. Likewise each action a filter may do beyond filing and flags: allowed on the Permissions page or with `PROTON_MCP_FILTER_ACTIONS`, and again in the popup. The extension's switches are off by default, and then it shows its window whatever the server sends, and refuses what its popup did not allow.
|
|
426
|
+
- **A browser tool an assistant drives cannot reach that window.** Measured with Claude in Chrome: the window is not among its tabs; on a web page there is no way to message the extension; a jump to the window's address from a web page ends on `chrome-extension://invalid/`; reading, finding and screenshots on an extension page fail.
|
|
427
|
+
- **Orders only from this server.** The extension declares no `externally_connectable`, so no web page can send it anything, and its script in the Proton page listens to the extension alone. The connector accepts connections only on a socket file in a directory only you can open, and only after the connection proves it knows the secret from the setup; the connector proves the same back. Every message is checked against a fixed list of seven kinds. Measured: a connection without the secret is cut off at its first request, and nothing reaches the extension. The limit: a program running as you can read the secret. It then meets the window like the server does, unless you turned on the extension's switches.
|
|
428
|
+
- **The right account.** Before anything is read or changed, the extension checks that the Proton page belongs to the account this server is signed in to, and once more right before each change.
|
|
429
|
+
- **What you agreed to, and nothing that changed since.** A change runs only while the filter, or the order, is still as it was when you agreed. A filter that does something you did not allow is neither replaced, applied nor switched on by an assistant. A change tried again after a lost answer first looks whether the first try already worked, so it does not save a filter twice.
|
|
430
|
+
- **Applying takes time.** Proton runs a filter over the existing mail in the background, which on a large mailbox takes hours, and applies no other filter meanwhile. An application you agreed to then waits in the extension, tried again at growing intervals of up to fifteen minutes, for as long as Chrome stays open; saving and moving go through meanwhile. The popup shows what waits and can cancel it, except while it is being tried. Restarting Chrome or the extension drops what waits, and the popup notes each one.
|
|
431
|
+
|
|
329
432
|
### What lands on your disk
|
|
330
433
|
|
|
331
434
|
Only these, all under `~/.config/proton-mcp/` (on Windows `%USERPROFILE%\.config\proton-mcp\`) and all written readable by your account alone:
|
|
@@ -333,13 +436,19 @@ Only these, all under `~/.config/proton-mcp/` (on Windows `%USERPROFILE%\.config
|
|
|
333
436
|
| File | When | What is in it |
|
|
334
437
|
| :--- | :--- | :--- |
|
|
335
438
|
| `known-certificate.json` | On the first connection | The Bridge's certificate and its fingerprint |
|
|
336
|
-
| `settings.json` | When you change
|
|
439
|
+
| `settings.json` | When you change something in the interface | The two Bridge ports, whether the notice that this is not an official Proton tool was dismissed, the PIN as a salted hash, and a counter that tells every running server to reread the stored credentials |
|
|
440
|
+
| `run/instances/` | While servers run | One small file per running server: process id, version, start time, whether it is signed in and as which address, and whether it serves the interface. No password |
|
|
337
441
|
| `credentials.enc` | Only if you chose the encrypted file | Your Bridge password, encrypted with your master password |
|
|
338
442
|
| `env` | Only if you chose the plain file, or put it there yourself | Your Bridge password in plain text |
|
|
339
443
|
| `saved-attachments/` | While attachments are saved | Which files this server saved, so it can delete exactly those again. No content |
|
|
444
|
+
| `browser/secret` | When you run `setup-browser` | The random secret the server and the connector prove to each other |
|
|
445
|
+
| `browser/host/` | When you run `setup-browser` | A copy of the connector and the launcher Chrome starts |
|
|
446
|
+
| `run/browser.sock` | While Chrome runs the connector | The socket the server reaches the connector through. Where the home path is too long for a socket, under `/tmp/proton-mcp-<your user id>/` instead; on Windows a named pipe |
|
|
340
447
|
|
|
341
448
|
Choosing the system keyring puts the password where the operating system keeps your other passwords, and nothing of it in a file. Choosing "this session only" writes nothing at all.
|
|
342
449
|
|
|
450
|
+
`setup-browser` also writes the file that tells Chrome which program the extension may start, where Chrome looks for it: `~/.config/google-chrome/NativeMessagingHosts/com.rndmjoker.proton_mcp.json` on Linux, the same name under `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/` on macOS, and on Windows in `browser/` above, named in a registry key under `HKEY_CURRENT_USER`. The extension keeps its switches, what filters may do, its last fifty changes with the filter names, and what waits for Proton, with the script, in its own storage in your Chrome profile; what waits is gone once it ran, was cancelled or Chrome restarted. A proposal waiting for your decision is kept in memory only.
|
|
451
|
+
|
|
343
452
|
**No mail, no subjects, no sender addresses, no folder names, no search terms.** The activity list in the interface records tool names and timings, in memory only, and is gone when the server stops.
|
|
344
453
|
|
|
345
454
|
### Saved attachments
|
|
@@ -376,11 +485,13 @@ The directory has to exist; it is not created for you, so that a mistyped path d
|
|
|
376
485
|
|
|
377
486
|
It binds to `127.0.0.1` and there is no option to change that. It is not remote access and cannot be made into it. Four defences, each against a specific attack:
|
|
378
487
|
|
|
379
|
-
- **
|
|
488
|
+
- **An optional PIN on every change.** Until 0.4.13 a token in the address kept other programs out, but that address reached the assistant, so it kept out everything except the one party the interface is meant to supervise. Set a PIN on the Permissions page and every change there asks for it: the assistant never sees it, so it can open the page but change nothing. It is stored only as a salted scrypt hash, and five wrong attempts lock changes for a minute. **Without a PIN, anything running on your machine can change the settings, an assistant with a browser tool included**, and the interface says so. Type the PIN only into a tab you are using yourself, since a tab an assistant drives at that moment can read what is typed into it. A forgotten PIN is removed by deleting the line `"pin"` from `settings.json`.
|
|
380
489
|
- **A Host header check.** A page on the internet can point its own domain at `127.0.0.1` and have your browser send requests there, which the browser then treats as same-origin with that domain. Only loopback names are accepted. This is DNS rebinding, and it is the attack local servers most often forget.
|
|
381
490
|
- **An Origin check and a per-action CSRF token** on everything that changes something, so a token for one action cannot be replayed against another.
|
|
382
491
|
- **A strict Content-Security-Policy.** Nothing may be loaded from anywhere and nothing can reach the network. The interface also serves no files from disk at all, which removes path traversal as a category rather than guarding against it.
|
|
383
492
|
|
|
493
|
+
Reading needs nothing: any program on your machine can open the pages, including the one listing your folder names. On a machine shared with other people, keep that in mind.
|
|
494
|
+
|
|
384
495
|
It shows no message content, with exactly one exception: a message waiting for you to confirm it. That page renders it as the recipient will see it, and lists every address, alt text and attachment. The message is held in memory only, dropped as soon as you answer either way, and gone after fifteen minutes. Somebody else's markup, which a reply carries as its quote, is rendered in a frame with no permissions at all, and images from the internet are not loaded: a tracking pixel must not tell its author that you looked.
|
|
385
496
|
|
|
386
497
|
### What you actually expose
|
|
@@ -437,7 +548,9 @@ The tests run without a Bridge, so they work in CI. Real credentials and real ma
|
|
|
437
548
|
```
|
|
438
549
|
src/
|
|
439
550
|
├── bridge/ connection handling, certificate pinning, error translation
|
|
551
|
+
├── browser/ the channel to the browser extension: connector, socket, setup
|
|
440
552
|
├── credentials/ the four places a password can be kept, and the runtime probe
|
|
553
|
+
├── filters/ the check every proposed Sieve script passes, and moving filters
|
|
441
554
|
├── mail/ listing, reading, searching, identifiers, attachments
|
|
442
555
|
├── mime/ parsing, HTML to text, budgeting, the parts of a stored draft
|
|
443
556
|
├── tools/ the MCP tools and their output formatting
|
|
@@ -448,19 +561,23 @@ src/
|
|
|
448
561
|
├── session.ts whether we are signed in, and where from
|
|
449
562
|
├── settings.ts the Bridge ports, when changed in the interface
|
|
450
563
|
└── server.ts entry point and transport
|
|
564
|
+
extension/ the browser extension Proton MCP Helper, built with WXT, not in the npm package
|
|
451
565
|
```
|
|
452
566
|
|
|
453
567
|
The web interface has one property worth knowing before touching it: `Referrer-Policy` must not be `no-referrer`. Under that policy browsers send `Origin: null`, which the CSRF defence rightly refuses, and the interface then refuses its own forms. The tests set the Origin header themselves and cannot catch it. There is a comment in `src/web/security.ts` and a test asserting the cause.
|
|
454
568
|
|
|
455
569
|
### Checks before a pull request
|
|
456
570
|
|
|
457
|
-
The CI in `.github/workflows/ci.yml` runs the tests, the type check, the build, an audit of the dependencies and a scan for accidentally committed secrets. All of it runs locally too:
|
|
571
|
+
The CI in `.github/workflows/ci.yml` runs the tests, the type check, the build, an audit of the dependencies and a scan for accidentally committed secrets, and type checks and builds the extension. All of it runs locally too:
|
|
458
572
|
|
|
459
573
|
```bash
|
|
574
|
+
npm ci --prefix extension # once: the extension's own dependencies, which its tests need
|
|
460
575
|
npm test
|
|
461
576
|
npm run typecheck
|
|
462
577
|
npm run build
|
|
463
578
|
npm audit --omit=dev
|
|
579
|
+
npm run typecheck --prefix extension
|
|
580
|
+
npm run build --prefix extension
|
|
464
581
|
```
|
|
465
582
|
|
|
466
583
|
### Measured behaviour of the Bridge
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire under the browser channel (#59): how messages are framed, and how
|
|
3
|
+
* the two ends of the socket recognise each other.
|
|
4
|
+
*
|
|
5
|
+
* Chrome talks to the connector over its standard input and output, each message
|
|
6
|
+
* a four-byte length in the machine's byte order followed by JSON. The connector
|
|
7
|
+
* and this server talk over a socket file, one JSON message per line.
|
|
8
|
+
*
|
|
9
|
+
* Before anything else on the socket, both ends prove that they know the
|
|
10
|
+
* secret the setup wrote, without sending it. The connector proves it as well,
|
|
11
|
+
* not only the server: a program that got to the socket name first could
|
|
12
|
+
* otherwise collect what the server sends, the account address and the
|
|
13
|
+
* proposed scripts among it.
|
|
14
|
+
*
|
|
15
|
+
* Copied next to the connector by the setup, so it imports nothing but Node.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The largest message accepted from Chrome, and the longest line on the
|
|
19
|
+
* socket. Both have to hold the largest answer the extension may give: a
|
|
20
|
+
* thousand filters of up to 60,000 characters each, as messages.ts allows.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_FROM_CHROME: number;
|
|
23
|
+
/** Chrome refuses anything larger from a native host and closes the channel. */
|
|
24
|
+
export declare const MAX_TO_CHROME: number;
|
|
25
|
+
/** The longest line accepted on the socket: an answer, which can be as large as one from Chrome. */
|
|
26
|
+
export declare const MAX_LINE: number;
|
|
27
|
+
/**
|
|
28
|
+
* The longest line the connector takes from a program on the socket. A
|
|
29
|
+
* request carries at most one script of 60,000 characters and fifty moves,
|
|
30
|
+
* so anything near this is not one, and is not kept in memory before the
|
|
31
|
+
* secret is even proven.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAX_REQUEST_LINE: number;
|
|
34
|
+
export declare class FrameError extends Error {
|
|
35
|
+
}
|
|
36
|
+
/** One message for Chrome: the length, then the JSON. */
|
|
37
|
+
export declare function encodeNative(message: unknown): Buffer;
|
|
38
|
+
/** Reads Chrome's framed messages out of a stream, however the chunks fall. */
|
|
39
|
+
export declare class NativeReader {
|
|
40
|
+
#private;
|
|
41
|
+
push(chunk: Buffer): unknown[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Splits a socket stream into lines, refusing one that never ends.
|
|
45
|
+
*
|
|
46
|
+
* Decodes across chunks: a character of more than one byte can be split
|
|
47
|
+
* between two of them, and decoding each chunk alone turned a letter such as
|
|
48
|
+
* an umlaut in a filter name into replacement characters, silently, found in
|
|
49
|
+
* the review of 06.11.2026.
|
|
50
|
+
*/
|
|
51
|
+
export declare class LineReader {
|
|
52
|
+
#private;
|
|
53
|
+
readonly max: number;
|
|
54
|
+
constructor(max?: number);
|
|
55
|
+
push(chunk: Buffer | string): string[];
|
|
56
|
+
}
|
|
57
|
+
/** Reads one line as a JSON object, or nothing when it is not one. */
|
|
58
|
+
export declare function parseLine(line: string): Record<string, unknown> | undefined;
|
|
59
|
+
export declare function newNonce(): string;
|
|
60
|
+
/** The proof of one end. The role is part of it, so a proof cannot be reflected back. */
|
|
61
|
+
export declare function proof(secret: Buffer, role: 'client' | 'host', nonce: string): string;
|
|
62
|
+
export declare function proofMatches(secret: Buffer, role: 'client' | 'host', nonce: string, presented: unknown): boolean;
|
|
63
|
+
/** True for a nonce as newNonce makes them. */
|
|
64
|
+
export declare function isNonce(value: unknown): value is string;
|
|
65
|
+
/** The secret from its file, or nothing when there is none or it is not one. */
|
|
66
|
+
export declare function readSecret(file: string): Buffer | undefined;
|
|
67
|
+
/** A directory that is not private enough to hold the socket. */
|
|
68
|
+
export declare class UnsafeDirectory extends Error {
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Creates the directory for the socket, readable by the user only, and refuses
|
|
72
|
+
* one that somebody else owns, that is a symbolic link, or that others can
|
|
73
|
+
* open. Only meaningful for socket files; on Windows the pipe has its own
|
|
74
|
+
* permissions.
|
|
75
|
+
*/
|
|
76
|
+
export declare function ensurePrivateDir(dir: string): void;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire under the browser channel (#59): how messages are framed, and how
|
|
3
|
+
* the two ends of the socket recognise each other.
|
|
4
|
+
*
|
|
5
|
+
* Chrome talks to the connector over its standard input and output, each message
|
|
6
|
+
* a four-byte length in the machine's byte order followed by JSON. The connector
|
|
7
|
+
* and this server talk over a socket file, one JSON message per line.
|
|
8
|
+
*
|
|
9
|
+
* Before anything else on the socket, both ends prove that they know the
|
|
10
|
+
* secret the setup wrote, without sending it. The connector proves it as well,
|
|
11
|
+
* not only the server: a program that got to the socket name first could
|
|
12
|
+
* otherwise collect what the server sends, the account address and the
|
|
13
|
+
* proposed scripts among it.
|
|
14
|
+
*
|
|
15
|
+
* Copied next to the connector by the setup, so it imports nothing but Node.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { endianness } from 'node:os';
|
|
19
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
20
|
+
import { lstatSync, mkdirSync, chmodSync, readFileSync } from 'node:fs';
|
|
21
|
+
/**
|
|
22
|
+
* The largest message accepted from Chrome, and the longest line on the
|
|
23
|
+
* socket. Both have to hold the largest answer the extension may give: a
|
|
24
|
+
* thousand filters of up to 60,000 characters each, as messages.ts allows.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_FROM_CHROME = 64 * 1024 * 1024;
|
|
27
|
+
/** Chrome refuses anything larger from a native host and closes the channel. */
|
|
28
|
+
export const MAX_TO_CHROME = 1024 * 1024;
|
|
29
|
+
/** The longest line accepted on the socket: an answer, which can be as large as one from Chrome. */
|
|
30
|
+
export const MAX_LINE = MAX_FROM_CHROME;
|
|
31
|
+
/**
|
|
32
|
+
* The longest line the connector takes from a program on the socket. A
|
|
33
|
+
* request carries at most one script of 60,000 characters and fifty moves,
|
|
34
|
+
* so anything near this is not one, and is not kept in memory before the
|
|
35
|
+
* secret is even proven.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_REQUEST_LINE = 1024 * 1024;
|
|
38
|
+
const LITTLE = endianness() === 'LE';
|
|
39
|
+
export class FrameError extends Error {
|
|
40
|
+
}
|
|
41
|
+
/** One message for Chrome: the length, then the JSON. */
|
|
42
|
+
export function encodeNative(message) {
|
|
43
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
|
44
|
+
if (body.length > MAX_TO_CHROME)
|
|
45
|
+
throw new FrameError(`A message of ${body.length} bytes is more than Chrome accepts.`);
|
|
46
|
+
const head = Buffer.alloc(4);
|
|
47
|
+
if (LITTLE)
|
|
48
|
+
head.writeUInt32LE(body.length);
|
|
49
|
+
else
|
|
50
|
+
head.writeUInt32BE(body.length);
|
|
51
|
+
return Buffer.concat([head, body]);
|
|
52
|
+
}
|
|
53
|
+
/** Reads Chrome's framed messages out of a stream, however the chunks fall. */
|
|
54
|
+
export class NativeReader {
|
|
55
|
+
#buffer = Buffer.alloc(0);
|
|
56
|
+
push(chunk) {
|
|
57
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
58
|
+
const messages = [];
|
|
59
|
+
for (;;) {
|
|
60
|
+
if (this.#buffer.length < 4)
|
|
61
|
+
return messages;
|
|
62
|
+
const length = LITTLE ? this.#buffer.readUInt32LE(0) : this.#buffer.readUInt32BE(0);
|
|
63
|
+
if (length > MAX_FROM_CHROME)
|
|
64
|
+
throw new FrameError(`A message of ${length} bytes is more than the connector accepts.`);
|
|
65
|
+
if (this.#buffer.length < 4 + length)
|
|
66
|
+
return messages;
|
|
67
|
+
const body = this.#buffer.subarray(4, 4 + length).toString('utf8');
|
|
68
|
+
this.#buffer = this.#buffer.subarray(4 + length);
|
|
69
|
+
try {
|
|
70
|
+
messages.push(JSON.parse(body));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new FrameError('A message from Chrome was not JSON.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Splits a socket stream into lines, refusing one that never ends.
|
|
80
|
+
*
|
|
81
|
+
* Decodes across chunks: a character of more than one byte can be split
|
|
82
|
+
* between two of them, and decoding each chunk alone turned a letter such as
|
|
83
|
+
* an umlaut in a filter name into replacement characters, silently, found in
|
|
84
|
+
* the review of 06.11.2026.
|
|
85
|
+
*/
|
|
86
|
+
export class LineReader {
|
|
87
|
+
max;
|
|
88
|
+
#pending = '';
|
|
89
|
+
#decoder = new StringDecoder('utf8');
|
|
90
|
+
constructor(max = MAX_LINE) {
|
|
91
|
+
this.max = max;
|
|
92
|
+
}
|
|
93
|
+
push(chunk) {
|
|
94
|
+
this.#pending += typeof chunk === 'string' ? chunk : this.#decoder.write(chunk);
|
|
95
|
+
const lines = this.#pending.split('\n');
|
|
96
|
+
this.#pending = lines.pop() ?? '';
|
|
97
|
+
if (this.#pending.length > this.max)
|
|
98
|
+
throw new FrameError('A line on the socket is longer than accepted.');
|
|
99
|
+
return lines.filter((line) => line.trim().length > 0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Reads one line as a JSON object, or nothing when it is not one. */
|
|
103
|
+
export function parseLine(line) {
|
|
104
|
+
try {
|
|
105
|
+
const value = JSON.parse(line);
|
|
106
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
107
|
+
? value
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function newNonce() {
|
|
115
|
+
return randomBytes(32).toString('hex');
|
|
116
|
+
}
|
|
117
|
+
/** The proof of one end. The role is part of it, so a proof cannot be reflected back. */
|
|
118
|
+
export function proof(secret, role, nonce) {
|
|
119
|
+
return createHmac('sha256', secret).update(`proton-mcp-browser:${role}:${nonce}`).digest('hex');
|
|
120
|
+
}
|
|
121
|
+
export function proofMatches(secret, role, nonce, presented) {
|
|
122
|
+
if (typeof presented !== 'string' || !/^[0-9a-f]{64}$/.test(presented))
|
|
123
|
+
return false;
|
|
124
|
+
return timingSafeEqual(Buffer.from(proof(secret, role, nonce), 'hex'), Buffer.from(presented, 'hex'));
|
|
125
|
+
}
|
|
126
|
+
/** True for a nonce as newNonce makes them. */
|
|
127
|
+
export function isNonce(value) {
|
|
128
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
129
|
+
}
|
|
130
|
+
/** The secret from its file, or nothing when there is none or it is not one. */
|
|
131
|
+
export function readSecret(file) {
|
|
132
|
+
try {
|
|
133
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
134
|
+
return /^[0-9a-f]{64}$/.test(text) ? Buffer.from(text, 'hex') : undefined;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** A directory that is not private enough to hold the socket. */
|
|
141
|
+
export class UnsafeDirectory extends Error {
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Creates the directory for the socket, readable by the user only, and refuses
|
|
145
|
+
* one that somebody else owns, that is a symbolic link, or that others can
|
|
146
|
+
* open. Only meaningful for socket files; on Windows the pipe has its own
|
|
147
|
+
* permissions.
|
|
148
|
+
*/
|
|
149
|
+
export function ensurePrivateDir(dir) {
|
|
150
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
151
|
+
const stat = lstatSync(dir);
|
|
152
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
153
|
+
throw new UnsafeDirectory(`${dir} is not a plain directory, so the socket is not placed there.`);
|
|
154
|
+
}
|
|
155
|
+
const uid = process.getuid?.();
|
|
156
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
157
|
+
throw new UnsafeDirectory(`${dir} belongs to another user, so the socket is not placed there.`);
|
|
158
|
+
}
|
|
159
|
+
if ((stat.mode & 0o077) !== 0)
|
|
160
|
+
chmodSync(dir, 0o700);
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=channel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.js","sourceRoot":"","sources":["../../src/browser/channel.ts"],"names":[],"mappings":"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"}
|