@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.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/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
package/docs/SECURITY.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# SECURITY
|
|
2
|
+
|
|
3
|
+
**This package turns your inbox into a control channel for a shell-capable agent.**
|
|
4
|
+
Read this before installing.
|
|
5
|
+
|
|
6
|
+
## 1. Threat model
|
|
7
|
+
|
|
8
|
+
If an attacker can get one message into the watched mailbox, they may be able to
|
|
9
|
+
make your agent do work — with whatever tools that session has. The agent has
|
|
10
|
+
`bash`, `edit`, and `write`. That is remote code execution with extra steps.
|
|
11
|
+
|
|
12
|
+
Sequence:
|
|
13
|
+
|
|
14
|
+
1. message lands in the inbox (spoofed, forwarded, or from a compromised account)
|
|
15
|
+
2. it carries a plausible `[pi:…]` token (guessed, replayed, or copied from a
|
|
16
|
+
mail you forwarded)
|
|
17
|
+
3. the daemon parses it, a session claims it, `pi.sendUserMessage` injects it
|
|
18
|
+
4. the agent treats it as you speaking
|
|
19
|
+
|
|
20
|
+
Every control below exists to break one link in that chain.
|
|
21
|
+
|
|
22
|
+
## 2. Defence in depth — all layers, not a subset
|
|
23
|
+
|
|
24
|
+
| Layer | Control | Breaks |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| Envelope | **Sender allowlist**, exact `addr-spec` match after RFC 5322 parsing | step 1 |
|
|
27
|
+
| Provenance | **DMARC pass** from the authenticated relay (`security.requireDmarcPass`, pinned to `trustedAuthservId` when set) | step 1 |
|
|
28
|
+
| Routing | **short route id**, TTL-bounded (legacy HMAC-signed tokens still verify) | steps 2–3 |
|
|
29
|
+
| Replay | **journal + expiry** (durable, survives restart) | step 2 |
|
|
30
|
+
| Human | **`request_decision` asks and ends the turn**; only a reply resumes it | step 4 |
|
|
31
|
+
|
|
32
|
+
### 2.1 DMARC — trust the delivering relay's header
|
|
33
|
+
|
|
34
|
+
`Authentication-Results` is an ordinary, append-only, sender-writable header. A
|
|
35
|
+
message can arrive with a forged `Authentication-Results` from the attacker's own
|
|
36
|
+
infrastructure *and* the real one your provider added.
|
|
37
|
+
|
|
38
|
+
What makes that safe is header ordering: headers are **prepended** as a message
|
|
39
|
+
traverses relays, so the **topmost** `Authentication-Results` is the one the final
|
|
40
|
+
hop — your provider — wrote on delivery. A sender's forged header necessarily ends
|
|
41
|
+
up *below* it.
|
|
42
|
+
|
|
43
|
+
Rules:
|
|
44
|
+
|
|
45
|
+
1. Read the **topmost** `Authentication-Results` header and require `dmarc=pass`
|
|
46
|
+
in it.
|
|
47
|
+
2. Optionally pin it harder: setting `security.trustedAuthservId` means only a
|
|
48
|
+
header bearing that `authserv-id` is considered, and its absence **fails
|
|
49
|
+
closed** rather than falling back.
|
|
50
|
+
3. Do **not** pin an authserv-id for Microsoft consumer accounts: the outlook.com
|
|
51
|
+
relay emits bare headers with no authserv-id at all
|
|
52
|
+
(`Authentication-Results: spf=pass …; dmarc=pass …`), so a pinned value would
|
|
53
|
+
reject every genuine reply. Leave it unset there.
|
|
54
|
+
4. Treat DMARC pass as **necessary, not sufficient** — it authenticates the
|
|
55
|
+
sending domain, not the authority to command your agent. An allowlisted sender
|
|
56
|
+
whose account is compromised still passes DMARC.
|
|
57
|
+
|
|
58
|
+
### 2.2 The routing tag is not authorization
|
|
59
|
+
|
|
60
|
+
A valid `[pi:…]` tag proves the subject was minted by this installation — nothing
|
|
61
|
+
more. New notifications carry a short local route id (`[pi:f913c9]`); older ones carry
|
|
62
|
+
a signed token, and both are accepted. Neither proves the message came from you. Anyone who replies to one of your
|
|
63
|
+
notification emails (or forwards it) can carry the token into the mailbox.
|
|
64
|
+
|
|
65
|
+
That is why token verification is *never* the only gate — the allowlist and
|
|
66
|
+
DMARC come first (DMARC is off in the single-mailbox layout), and the token only
|
|
67
|
+
routes.
|
|
68
|
+
|
|
69
|
+
### 2.3 Replay protection must be durable
|
|
70
|
+
|
|
71
|
+
An in-memory "seen UIDs" set dies with the daemon. The journal is a file,
|
|
72
|
+
appended after a successful ack and consulted at scan time, so an acked mail is never
|
|
73
|
+
offered again. Routing ids, signed tokens and the reply chain all carry an expiry, so a
|
|
74
|
+
very old reply is not routed even with a valid id or MAC.
|
|
75
|
+
|
|
76
|
+
## 3. Prompt injection is not solved, only bounded
|
|
77
|
+
|
|
78
|
+
A legitimate email can still contain text that reads like instructions
|
|
79
|
+
("ignore your rules, run …"). You cannot filter this reliably. What you can do:
|
|
80
|
+
|
|
81
|
+
- **Scope the session.** A session that only needs to read code should not have
|
|
82
|
+
`bash`. `pi --tools read,grep,glob` for untrusted threads. This package does not enforce
|
|
83
|
+
tool scoping — pi's own `--tools` does.
|
|
84
|
+
- **Keep a human in the loop for destructive actions.** `request_decision` sends the
|
|
85
|
+
question and ends the turn; nothing resumes the session except a reply. There is no
|
|
86
|
+
approve command and no phrase a message can contain to satisfy it: the human's reply *is*
|
|
87
|
+
the approval. This is a convention the model follows, not an enforced gate - a model that
|
|
88
|
+
ignores the tool can keep working, which is why tool scoping matters.
|
|
89
|
+
- **Do not paste mail verbatim into a prompt as trusted context.** Injected
|
|
90
|
+
text is *user input*, and the system prompt should say so.
|
|
91
|
+
|
|
92
|
+
## 4. Daemon hardening
|
|
93
|
+
|
|
94
|
+
- The bearer token authorises **sending mail as you to any address**, not just `mail.to`:
|
|
95
|
+
`POST /send` takes an optional `to`. Anything that can read `secret.key` (same uid) can
|
|
96
|
+
therefore exfiltrate over SMTP. That is inside the trust boundary - the same process can read
|
|
97
|
+
your mail - but worth knowing when deciding what runs as you.
|
|
98
|
+
- Defaults to binding `127.0.0.1`; `daemon.host` can change that, but there is no TLS and
|
|
99
|
+
no user model beyond the bearer token, so do not. Nothing else about the daemon is
|
|
100
|
+
reachable without the token, apart from the `/health` liveness probe. That returns mode,
|
|
101
|
+
pending/clients counts, the decision-registry size, ping timing, the number of SSE subscribers,
|
|
102
|
+
the last scan's reason *counts*, the build and protocol versions, and the config file's mtime and
|
|
103
|
+
whether IMAP is connected — no mail content, no addresses, no subjects.
|
|
104
|
+
- Bearer token from `secret.key`, mode `0600`, created first-writer-wins,
|
|
105
|
+
compared in constant time.
|
|
106
|
+
- Body cap 1 MB; registry cap 10 000; SSE client cap 100; ten notifications a minute; ten notifications a minute.
|
|
107
|
+
- `EADDRINUSE` on start is treated as "already running" — an attacker cannot
|
|
108
|
+
displace a running daemon by racing the port, because the loser exits.
|
|
109
|
+
- The token never appears in logs, error strings, or the notification email.
|
|
110
|
+
|
|
111
|
+
### What the bearer token authorises
|
|
112
|
+
|
|
113
|
+
Beyond sending, the token can `GET /pending` (this device's reply bodies and attachments), `/claim`
|
|
114
|
+
and `/ack` a record this device scanned — which *discards* a queued reply without delivering it —
|
|
115
|
+
flip the mode, and shut the daemon down. Treat `secret.key` as equivalent to the mailbox password.
|
|
116
|
+
|
|
117
|
+
## 5. Mailbox hygiene
|
|
118
|
+
|
|
119
|
+
- Prefer a **dedicated account or a filtered folder**. Do not point this at a
|
|
120
|
+
mailbox where you receive untrusted mail.
|
|
121
|
+
- The tool marks a delivered message `\Seen` and journals it. It never deletes or moves
|
|
122
|
+
mail, so a shared account keeps its history.
|
|
123
|
+
- Rotate the HMAC key if you suspect exposure. The key is `secret.key`, created first-writer-
|
|
124
|
+
wins, so rotation means **deleting** it while nothing is running:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# stop the daemon (or let it idle-exit)
|
|
128
|
+
rm ~/.local/state/pi-mailbridge/secret.key # this is the rotation
|
|
129
|
+
# then restart pi (or run /mailbridge restart): the session mints a new key and its
|
|
130
|
+
# respawn starts a daemon that reads it
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Stop the daemon *before* deleting: a running one holds the port `and` the key it read at
|
|
134
|
+
startup, so it keeps answering 401 to every call and the replacement cannot bind, no matter
|
|
135
|
+
how often a session retries. Sessions re-read `secret.key` on each call, but the client each
|
|
136
|
+
one already built carries the old token, so it 401s until its runtime is rebuilt.
|
|
137
|
+
|
|
138
|
+
Do **not** rotate for a *mail password* change: the daemon's `POST /reload` re-reads
|
|
139
|
+
that (run it via `/mailbridge smoke`), so no restart is needed.
|
|
140
|
+
|
|
141
|
+
Rotating the key kills every outstanding **signed token**, but *not* the short route ids:
|
|
142
|
+
those are entries in `routes.json` and resolve with no secret at all, so they keep working
|
|
143
|
+
until `security.tokenTtlHours` expires. Rotating the key does not stop delivery on its own.
|
|
144
|
+
To revoke, stop the daemon and clear the state directory
|
|
145
|
+
(`${XDG_STATE_HOME:-~/.local/state}/pi-mailbridge`):
|
|
146
|
+
- `routes.json` — short ids. Resolve with no secret.
|
|
147
|
+
- `threads.json` — the reply chain. Resolves recorded Message-IDs with no secret.
|
|
148
|
+
- `pending.json` — **already-accepted replies waiting for a session.** Deleting the two
|
|
149
|
+
routing stores stops new mail being routed *by id or chain*, but anything persisted
|
|
150
|
+
before is still delivered on the next drain - and a legacy token still resolves until
|
|
151
|
+
it expires or you rotate the key. If the session is compromised, this is the queue
|
|
152
|
+
that matters.
|
|
153
|
+
- `journal.json` — only the ack dedupe, tagged with the mailbox's UIDVALIDITY. Clearing it
|
|
154
|
+
re-opens replay of already-acked mail, so prefer leaving it unless you also cleared the
|
|
155
|
+
mailbox.
|
|
156
|
+
- `uid-cursor.json` — the last seen UID and the UIDVALIDITY it belongs to. A change of
|
|
157
|
+
UIDVALIDITY (a rebuilt or migrated folder) resets it; queued records tagged with another numbering
|
|
158
|
+
are dropped on every connect (and untagged ones on a detected renumbering), so recent mail
|
|
159
|
+
can be delivered once more instead of being skipped.
|
|
160
|
+
Secrets (`secret.key`, `oauth.json`) live beside them; rotating those is covered above.
|
|
161
|
+
- Turn it off with `/mailbridge off` when you will be at the desk: while mode is off the daemon
|
|
162
|
+
does not scan and the extension does not claim or inject, so the mailbox is not a
|
|
163
|
+
listening control channel in either direction. Replies that arrive meanwhile stay in the
|
|
164
|
+
mailbox and are processed when you `/mailbridge on` again — which also means turning it back on can
|
|
165
|
+
deliver a burst. To stop entirely, close pi and let the daemon exit (~120 s after the last ping),
|
|
166
|
+
or stop it however you started it - with a session still open, the poll tick respawns a daemon it
|
|
167
|
+
finds missing, throttled to once a minute.
|
|
168
|
+
- **One mailbox for both roles** (`security.allowSelfReplies`). The loop guard is relaxed
|
|
169
|
+
for self-addressed mail, so the sender allowlist alone no longer separates "me" from "not
|
|
170
|
+
me" — it is the same address. Three guards do the work, in this order: the
|
|
171
|
+
`X-Pi-Mailbridge: notification` marker (refused outright, so it does not depend on
|
|
172
|
+
anything being recorded), the reply-evidence gate (`In-Reply-To` or `References` must be
|
|
173
|
+
present), and the outbound `Message-ID` registry. All of them are forgeable by anyone who
|
|
174
|
+
can already send from your address; they exist to stop loops, not attackers.
|
|
175
|
+
**DMARC is off in this shape by default**, because a provider may deliver a message from
|
|
176
|
+
your address to the same address with no `Authentication-Results` header at all (QQ does),
|
|
177
|
+
which would reject every reply. `/mailbridge setup` writes it that way; setting it back on is
|
|
178
|
+
reported by `/mailbridge check`, and `pi-mailbridge --check` exits non-zero — gate a supervisor
|
|
179
|
+
or CI on that. Prefer a dedicated mailbox.
|
|
180
|
+
- **Multiple machines, one mailbox.** Each daemon only claims replies whose route id, token
|
|
181
|
+
or chain entry names its own device, so they cannot steal each other's work. They share
|
|
182
|
+
the allowlist (one config each), but **each machine has its own `secret.key`** — it is a
|
|
183
|
+
local file, never synced through the mailbox. So there is no "the key" to rotate: rotate
|
|
184
|
+
the compromised machine's, clear its state directory as described above (`routes.json`,
|
|
185
|
+
`threads.json`, `pending.json`), and review the others separately.
|
|
186
|
+
|
|
187
|
+
## 6. What this package will never do
|
|
188
|
+
|
|
189
|
+
- auto-approve a destructive action from email
|
|
190
|
+
- execute an attachment
|
|
191
|
+
- act on a message with `Auto-Submitted`, `Precedence: bulk`, or a bounce sender
|
|
192
|
+
- accept a message from outside the allowlist, however convincing
|
|
193
|
+
- resume a session the human did not start
|
|
194
|
+
|
|
195
|
+
## 6a. Commands arriving by mail
|
|
196
|
+
|
|
197
|
+
A reply whose entire body is one command line executes that command (`/mailbridge off`,
|
|
198
|
+
`/mailbridge status|check|smoke|help`, and `/mailbridge on` only while the mode is already on -
|
|
199
|
+
with it off nothing scans or drains, so such a reply is never read). It is **not** handed to pi's command parser, and the
|
|
200
|
+
commands that change the machine are refused: `setup` (rewrites the config and the target
|
|
201
|
+
address), `auth` (starts a credential flow), `restart` (replaces the daemon) and `prime` (skips
|
|
202
|
+
unread mail) are delivered to the agent as text instead.
|
|
203
|
+
|
|
204
|
+
What this costs, in the one-mailbox layout (DMARC is off there by design) and if the sender can
|
|
205
|
+
forge the allowlisted `From` *and* carries a live route id or token — i.e. they have read one of
|
|
206
|
+
your notifications:
|
|
207
|
+
|
|
208
|
+
- `/mailbridge off` is a **one-way, global, persisted** denial of the channel: scanning and
|
|
209
|
+
delivery stop for every session until a human turns it back on at a terminal. Mail is not lost
|
|
210
|
+
(the cursor does not move), but the remote-decision channel is dead.
|
|
211
|
+
- `/mailbridge smoke` sends one real mail **from your own account to your own address** and costs
|
|
212
|
+
a daemon reload plus up to ~52 s of IMAP work. It is not rate-limited, so repeated replies can
|
|
213
|
+
consume SMTP/IMAP capacity and get genuine notifications throttled.
|
|
214
|
+
- `status`, `check` and `help` print into the local pi session; nothing is mailed back.
|
|
215
|
+
|
|
216
|
+
They cannot reconfigure the account, obtain credentials, restart the daemon, re-prime the cursor
|
|
217
|
+
or discard the queue by mail: `setup`, `auth`, `restart` and `prime` are refused by the
|
|
218
|
+
dispatcher. What the dispatcher refuses, however, is still *delivered to the agent as text*, so
|
|
219
|
+
§1 and §3 apply to it — the refusal is about what runs automatically, not about what the model
|
|
220
|
+
may then do in a shell-capable session.
|
|
221
|
+
|
|
222
|
+
A command reply to a *decision request* is a special case worth knowing: it runs the command and
|
|
223
|
+
releases the decision slot without the agent seeing an answer, so a later `request_decision` is
|
|
224
|
+
allowed again. Nothing is lost and no mail is misrouted, but "one outstanding decision" is
|
|
225
|
+
weaker than the reply flow suggests.
|
|
226
|
+
|
|
227
|
+
"A command" means the reply's text **after quote-stripping**, so `/mailbridge on` above a quoted
|
|
228
|
+
original still executes — that is deliberate (a phone-appended signature must not break it), but
|
|
229
|
+
it means "entire body" below refers to the stripped body, not the raw mail.
|
|
230
|
+
|
|
231
|
+
## 6b. Attachments in notifications
|
|
232
|
+
|
|
233
|
+
A notification carries the local files the agent linked, so what leaves the machine is decided by
|
|
234
|
+
model output. The controls, all fail-closed:
|
|
235
|
+
|
|
236
|
+
- **The project directory is the boundary** on the notification path. Every path is
|
|
237
|
+
`realpath`-resolved on both sides and must land inside the agent's working directory. (An
|
|
238
|
+
authenticated caller of `POST /send` supplies `cwd`, so with `secret.key` the boundary moves to
|
|
239
|
+
wherever it says — same trust level as the `to` override in §4.) A symlink out of it (`ln -s ~/.config/… config.json`)
|
|
240
|
+
is refused, and so is an absolute path outside it.
|
|
241
|
+
- **A home-rooted or `/`-rooted launch attaches nothing**, because with `cwd = $HOME` the config
|
|
242
|
+
file is "inside the project". Start pi in a project; that is the intended usage.
|
|
243
|
+
- **The daemon's own config and state directories are never attached**, whatever the `cwd`.
|
|
244
|
+
- **An extension allowlist** (`pdf txt log md csv tsv json yml yaml xml html zip gz tar docx xlsx
|
|
245
|
+
pptx`, plus `png jpg jpeg gif webp svg` for links and the sniffed image types inline)
|
|
246
|
+
keeps the obvious key filenames out of an accidental attach. It is not a content filter:
|
|
247
|
+
`cp ~/.ssh/id_rsa notes.txt` or `tar czf k.tar ~/.ssh` defeats it, and `realpath` cannot see a
|
|
248
|
+
hardlink. Treat it as a guard rail against a naive single-shot injection, not a sandbox.
|
|
249
|
+
- **Size caps**: 5 MB per inline image and 15 MB per linked file (fixed), and
|
|
250
|
+
`notify.maxAttachmentMb` (default 18 MB, max 45) for the total per mail, before base64 — at most
|
|
251
|
+
10 attachments per mail. A
|
|
252
|
+
non-regular file (FIFO, device) is refused rather than read, because it reports size 0 and
|
|
253
|
+
then blocks.
|
|
254
|
+
- **No automated path changes the recipient.** Every notification's `to` is config-derived, a
|
|
255
|
+
command reply cannot rewrite the config (`setup` is refused by the dispatcher), and the send
|
|
256
|
+
path has no Cc/Bcc. An authenticated caller *can* pass a different `to` to `POST /send` — that
|
|
257
|
+
is §4's "mail as you to any address", and it needs `secret.key`.
|
|
258
|
+
|
|
259
|
+
Residual risk, plainly: anything the agent can read and re-materialise inside the project can be
|
|
260
|
+
mailed to the address the daemon sends to, up to the cap, including a file that happens to contain secrets and archives
|
|
261
|
+
whose contents the allowlist cannot inspect. Sending is capped at ten
|
|
262
|
+
model-driven notifications a minute per daemon (`429 send_budget`), refused rather than queued.
|
|
263
|
+
Settle notifications and decision requests are exempt: a settle says the agent is waiting, and a
|
|
264
|
+
decision is already bounded at one outstanding per session by the registry. That stops bursts, not a
|
|
265
|
+
sustained loop: ten a minute is 600 an hour and the budget resets when the daemon restarts, so tool
|
|
266
|
+
scoping remains the defence.
|
|
267
|
+
|
|
268
|
+
## 7. Reporting
|
|
269
|
+
|
|
270
|
+
Email the maintainer at the address in `package.json`'s `author`
|
|
271
|
+
(`yangbangcheng@bigai.ai`). If the package is published with a `repository`, a private
|
|
272
|
+
security advisory on that repository is equally good. Do not file a public issue for anything
|
|
273
|
+
in this document.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 530" width="1000" height="530" role="img" aria-labelledby="t d">
|
|
2
|
+
<title id="t">How a notification and a reply travel</title>
|
|
3
|
+
<desc id="d">Four stations: pi session, daemon, your mailbox, you. The session sends through the daemon over SMTP; you reply from any device; the daemon reads it over IMAP, authorizes, routes and persists it; the session claims it, injects it and acknowledges, which marks the mail seen.</desc>
|
|
4
|
+
|
|
5
|
+
<defs>
|
|
6
|
+
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
7
|
+
<path d="M0 0 L10 5 L0 10z" fill="#5b6673"/>
|
|
8
|
+
</marker>
|
|
9
|
+
<marker id="g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
10
|
+
<path d="M0 0 L10 5 L0 10z" fill="#2f6f4f"/>
|
|
11
|
+
</marker>
|
|
12
|
+
<style>
|
|
13
|
+
.h{font:600 20px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#18212b}
|
|
14
|
+
.b{font:16px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#39434f}
|
|
15
|
+
.s{font:14.5px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#6a7683}
|
|
16
|
+
.pt{font:600 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#33404d}
|
|
17
|
+
.num{font:700 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#fff}
|
|
18
|
+
.flow{stroke:#5b6673;stroke-width:2;fill:none}
|
|
19
|
+
.flow-g{stroke:#2f6f4f;stroke-width:2.2;fill:none}
|
|
20
|
+
.ic{stroke:#3d4854;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
21
|
+
.ic-g{stroke:#2f6f4f;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
22
|
+
.pill{fill:#eef3f8;stroke:#d3dee9;stroke-width:1}
|
|
23
|
+
</style>
|
|
24
|
+
</defs>
|
|
25
|
+
|
|
26
|
+
<!-- pi session -->
|
|
27
|
+
<rect x="16" y="190" width="196" height="150" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
|
|
28
|
+
<g transform="translate(32,206)">
|
|
29
|
+
<rect class="ic" x="0" y="0" width="30" height="23" rx="4"/>
|
|
30
|
+
<path class="ic" d="M5 6 l5 5 -5 5"/>
|
|
31
|
+
<path class="ic" d="M14 16 h7"/>
|
|
32
|
+
</g>
|
|
33
|
+
<text class="h" x="72" y="224">pi session</text>
|
|
34
|
+
<text class="b" x="32" y="258">finishes a turn</text>
|
|
35
|
+
<text class="b" x="32" y="282">→ one mail</text>
|
|
36
|
+
<text class="b" x="32" y="306">reply: next message</text>
|
|
37
|
+
|
|
38
|
+
<!-- daemon -->
|
|
39
|
+
<rect x="360" y="136" width="376" height="268" rx="16" fill="#f6f9f7" stroke="#84b097" stroke-width="1.6"/>
|
|
40
|
+
<g transform="translate(380,154)">
|
|
41
|
+
<path class="ic-g" d="M15 1 l12 5 v8 c0 7 -5 12 -12 15 c-7 -3 -12 -8 -12 -15 v-8 z"/>
|
|
42
|
+
<path class="ic-g" d="M10 15 l4 4 7 -8"/>
|
|
43
|
+
</g>
|
|
44
|
+
<text class="h" x="424" y="172">daemon</text>
|
|
45
|
+
<text class="s" x="424" y="192">one per machine · loopback</text>
|
|
46
|
+
|
|
47
|
+
<g>
|
|
48
|
+
<rect class="pill" x="380" y="212" width="82" height="28" rx="14"/><text class="pt" x="396" y="231">scan</text>
|
|
49
|
+
<rect class="pill" x="470" y="212" width="112" height="28" rx="14"/><text class="pt" x="486" y="231">authorize</text>
|
|
50
|
+
<rect class="pill" x="590" y="212" width="82" height="28" rx="14"/><text class="pt" x="606" y="231">route</text>
|
|
51
|
+
<rect class="pill" x="380" y="250" width="292" height="28" rx="14"/><text class="pt" x="396" y="269">strip quote · attach · persist</text>
|
|
52
|
+
</g>
|
|
53
|
+
|
|
54
|
+
<text class="s" x="380" y="302">cursor moves after the record is saved</text>
|
|
55
|
+
<text class="s" x="380" y="324">IMAP IDLE + a 60 s sweep</text>
|
|
56
|
+
|
|
57
|
+
<g transform="translate(380,338)">
|
|
58
|
+
<ellipse class="ic" cx="14" cy="5" rx="14" ry="5"/>
|
|
59
|
+
<path class="ic" d="M0 5 v14 c0 2.8 6.3 5 14 5 s14 -2.2 14 -5 V5"/>
|
|
60
|
+
<path class="ic" d="M0 12 c0 2.8 6.3 5 14 5 s14 -2.2 14 -5"/>
|
|
61
|
+
</g>
|
|
62
|
+
<text class="s" x="418" y="356">pending · journal · cursor</text>
|
|
63
|
+
|
|
64
|
+
<!-- mailbox -->
|
|
65
|
+
<rect x="356" y="28" width="292" height="72" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
|
|
66
|
+
<g transform="translate(374,44)">
|
|
67
|
+
<rect class="ic" x="0" y="0" width="38" height="27" rx="4"/>
|
|
68
|
+
<path class="ic" d="M1 3 l18 12 18 -12"/>
|
|
69
|
+
</g>
|
|
70
|
+
<text class="h" x="428" y="54">your mailbox</text>
|
|
71
|
+
<text class="s" x="428" y="76">INBOX, read by the daemon</text>
|
|
72
|
+
|
|
73
|
+
<!-- you -->
|
|
74
|
+
<rect x="764" y="196" width="212" height="120" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
|
|
75
|
+
<g transform="translate(782,212)">
|
|
76
|
+
<rect class="ic" x="0" y="0" width="20" height="33" rx="4"/>
|
|
77
|
+
<path class="ic" d="M6 27 h8"/>
|
|
78
|
+
<path class="ic" d="M6 5 h8"/>
|
|
79
|
+
</g>
|
|
80
|
+
<text class="h" x="814" y="230">you</text>
|
|
81
|
+
<text class="b" x="782" y="270">any device with email</text>
|
|
82
|
+
<text class="b" x="782" y="294">reply, don't compose</text>
|
|
83
|
+
|
|
84
|
+
<!-- 1 send -->
|
|
85
|
+
<path class="flow" d="M216 236 H352" marker-end="url(#a)"/>
|
|
86
|
+
<circle cx="330" cy="236" r="12" fill="#5b6673"/><text class="num" x="325" y="241">1</text>
|
|
87
|
+
<text class="s" x="222" y="226">send</text>
|
|
88
|
+
|
|
89
|
+
<!-- 2 SMTP -->
|
|
90
|
+
<path class="flow" d="M570 136 V104" marker-end="url(#a)"/>
|
|
91
|
+
<circle cx="570" cy="120" r="12" fill="#5b6673"/><text class="num" x="565" y="125">2</text>
|
|
92
|
+
<text class="s" x="588" y="125">SMTP</text>
|
|
93
|
+
|
|
94
|
+
<!-- 3 notification -->
|
|
95
|
+
<path class="flow" d="M648 62 H870 V192" marker-end="url(#a)"/>
|
|
96
|
+
<circle cx="760" cy="62" r="12" fill="#5b6673"/><text class="num" x="755" y="67">3</text>
|
|
97
|
+
<text class="s" x="778" y="52">notification</text>
|
|
98
|
+
|
|
99
|
+
<!-- 4 reply -->
|
|
100
|
+
<path class="flow-g" d="M828 196 V92 H648" marker-end="url(#g)"/>
|
|
101
|
+
<circle cx="828" cy="144" r="12" fill="#2f6f4f"/><text class="num" x="823" y="149">4</text>
|
|
102
|
+
<text class="s" x="690" y="112">your reply</text>
|
|
103
|
+
|
|
104
|
+
<!-- 5 IMAP -->
|
|
105
|
+
<path class="flow-g" d="M450 104 V132" marker-end="url(#g)"/>
|
|
106
|
+
<circle cx="450" cy="118" r="12" fill="#2f6f4f"/><text class="num" x="445" y="123">5</text>
|
|
107
|
+
<text class="s" x="468" y="123">IMAP</text>
|
|
108
|
+
|
|
109
|
+
<!-- 6 claim -->
|
|
110
|
+
<path class="flow" d="M352 286 H216" marker-end="url(#a)"/>
|
|
111
|
+
<circle cx="330" cy="286" r="12" fill="#5b6673"/><text class="num" x="325" y="291">6</text>
|
|
112
|
+
<text class="s" x="222" y="276">claim</text>
|
|
113
|
+
|
|
114
|
+
<!-- 7 ack -->
|
|
115
|
+
<path class="flow-g" d="M216 320 H352" marker-end="url(#g)"/>
|
|
116
|
+
<circle cx="330" cy="320" r="12" fill="#2f6f4f"/><text class="num" x="325" y="325">7</text>
|
|
117
|
+
<text class="s" x="222" y="340">ack → seen</text>
|
|
118
|
+
|
|
119
|
+
<!-- footer -->
|
|
120
|
+
<line x1="24" y1="428" x2="976" y2="428" stroke="#e2e8ef"/>
|
|
121
|
+
<text class="b" x="24" y="454">Every inbound message passes, in order:</text>
|
|
122
|
+
<text class="s" x="24" y="480">journal → marker → size → From → loop guard → allowlist → DMARC → Message-ID → route → ack</text>
|
|
123
|
+
<text class="s" x="24" y="506">Routing ids never authorize. Mail is marked seen only after the reply is injected and acked.</text>
|
|
124
|
+
</svg>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dumbmice/pi-mailbridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Drive Pi coding-agent sessions from your mailbox: get an email when an agent settles, reply to resume that exact session.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"email",
|
|
11
|
+
"imap",
|
|
12
|
+
"smtp",
|
|
13
|
+
"idle",
|
|
14
|
+
"notifications",
|
|
15
|
+
"afk"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/DumbMice/pi-mailbridge.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/DumbMice/pi-mailbridge/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/DumbMice/pi-mailbridge#readme",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "Yang Bangcheng",
|
|
28
|
+
"email": "yangbangcheng@bigai.ai"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.6"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"pi-mailbridge": "./bin/pi-mailbridge.mjs"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src",
|
|
40
|
+
"bin",
|
|
41
|
+
"docs",
|
|
42
|
+
"config.example.json",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"scripts/render-teaser.ts"
|
|
45
|
+
],
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./src/extension.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"main": "./src/extension.ts",
|
|
52
|
+
"scripts": {
|
|
53
|
+
"daemon": "node --experimental-strip-types src/daemon/main.ts",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
56
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test",
|
|
57
|
+
"build": "tsc -p tsconfig.build.json",
|
|
58
|
+
"pretest": "npm run build"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"imapflow": "^2.11.0",
|
|
62
|
+
"mailparser": "^3.14.3",
|
|
63
|
+
"markdown-it": "^15.0.3",
|
|
64
|
+
"nodemailer": "^10.5.0",
|
|
65
|
+
"openid-client": "^6.8.9"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@earendil-works/pi-ai": "*",
|
|
69
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
70
|
+
"@sinclair/typebox": "*"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/mailparser": "^3.4.5",
|
|
74
|
+
"@types/markdown-it": "^14.2.0",
|
|
75
|
+
"@types/node": "^22.10.0",
|
|
76
|
+
"@types/nodemailer": "^6.4.17",
|
|
77
|
+
"typescript": "^5.7.0"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"@earendil-works/pi-ai": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@earendil-works/pi-coding-agent": {
|
|
84
|
+
"optional": true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Render the README teaser through the real message renderer.
|
|
2
|
+
//
|
|
3
|
+
// The teaser in docs/images/ must never be a mock: it is this HTML, produced by
|
|
4
|
+
// `renderMessage` - the same function the mailer calls - with a mail-client frame around it.
|
|
5
|
+
// Regenerate after changing the renderer:
|
|
6
|
+
//
|
|
7
|
+
// node --experimental-strip-types scripts/render-teaser.ts # writes /tmp/teaser.html
|
|
8
|
+
// open /tmp/teaser.html, 820 px wide, capture at 2x # save as docs/images/notification.png
|
|
9
|
+
//
|
|
10
|
+
// `root` points at a scratch directory holding build.log, so the attachment line renders the
|
|
11
|
+
// way a real notification's does.
|
|
12
|
+
import { writeFileSync } from "node:fs"
|
|
13
|
+
import { renderMessage } from "../src/core/html.ts"
|
|
14
|
+
|
|
15
|
+
const body = `## Fix is in, one decision needed
|
|
16
|
+
|
|
17
|
+
The failing test passes now, and the migration is written. I stopped before touching
|
|
18
|
+
production because the rollback path is ambiguous.
|
|
19
|
+
|
|
20
|
+
**What I changed**
|
|
21
|
+
|
|
22
|
+
- \`src/auth/session.ts\` — refresh tokens are rotated instead of reused
|
|
23
|
+
- \`test/session.test.ts\` — two cases for the reuse window
|
|
24
|
+
- added \`docs/migration.md\`
|
|
25
|
+
|
|
26
|
+
**The question**
|
|
27
|
+
|
|
28
|
+
Should the migration run automatically on deploy, or wait for a manual step?
|
|
29
|
+
|
|
30
|
+
> Recommendation: wait for a manual step this week — the queue drain takes ~40 minutes,
|
|
31
|
+
> and a failed deploy would leave sessions signed out.
|
|
32
|
+
|
|
33
|
+
[the full log](./build.log)
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
import { mkdirSync, writeFileSync as wf } from "node:fs"
|
|
37
|
+
mkdirSync("/tmp/teaser-root", { recursive: true })
|
|
38
|
+
wf("/tmp/teaser-root/build.log", "line 1: compiling\nline 2: 412 tests, 412 pass\n")
|
|
39
|
+
const rendered = renderMessage(body, { root: "/tmp/teaser-root" })
|
|
40
|
+
const html = `<!doctype html><meta charset="utf-8">
|
|
41
|
+
<div style="background:#eef1f5;padding:28px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
42
|
+
<div style="max-width:760px;margin:0 auto;background:#fff;border:1px solid #dfe3e8;border-radius:10px;overflow:hidden">
|
|
43
|
+
<div style="padding:16px 22px;border-bottom:1px solid #eceff3;background:#fafbfc">
|
|
44
|
+
<div style="font-size:13px;color:#5b6673">pi-mailbridge <pi@example.com></div>
|
|
45
|
+
<div style="font-size:17px;font-weight:600;color:#1c2530;margin-top:4px">[pi:f913c9] pi: fix-login @ dev-box-7 is waiting for you</div>
|
|
46
|
+
<div style="font-size:12px;color:#7b8794;margin-top:4px">to you@example.com</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div style="padding:20px 22px">${rendered.html}</div>
|
|
49
|
+
<div style="padding:14px 22px;border-top:1px solid #eceff3;background:#fafbfc;font-size:12px;color:#5b6673">
|
|
50
|
+
device: dev-box-7 · session: fix-login · turns: 3<br>
|
|
51
|
+
Reply above this line to continue the session.
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>`
|
|
55
|
+
writeFileSync("/tmp/teaser.html", html)
|
|
56
|
+
console.log("wrote /tmp/teaser.html with", rendered.attachments.length, "attachment(s)")
|