waterplane 0.1.19 → 0.1.22

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/NOTICES.md +0 -26
  3. package/README.md +21 -20
  4. package/SECURITY.md +575 -562
  5. package/node_modules/@waterplane/shared/dist/clihealth/healthcatalog.js +1 -0
  6. package/node_modules/@waterplane/shared/dist/clihealth/healthcatalog.js.map +1 -1
  7. package/node_modules/@waterplane/shared/dist/exposure.js +0 -1
  8. package/node_modules/@waterplane/shared/dist/exposure.js.map +1 -1
  9. package/node_modules/@waterplane/shared/dist/hints.js +0 -1
  10. package/node_modules/@waterplane/shared/dist/hints.js.map +1 -1
  11. package/node_modules/@waterplane/shared/dist/plugin/api.d.ts +11 -2
  12. package/node_modules/@waterplane/shared/dist/plugin/manifest.d.ts +18 -0
  13. package/node_modules/@waterplane/shared/dist/plugin/manifest.js +9 -0
  14. package/node_modules/@waterplane/shared/dist/plugin/manifest.js.map +1 -1
  15. package/node_modules/@waterplane/shared/dist/plugin/version.d.ts +3 -1
  16. package/node_modules/@waterplane/shared/dist/plugin/version.js +3 -1
  17. package/node_modules/@waterplane/shared/dist/plugin/version.js.map +1 -1
  18. package/node_modules/@waterplane/shared/dist/pluginmaturity.d.ts +1 -1
  19. package/node_modules/@waterplane/shared/dist/pluginmaturity.js +1 -1
  20. package/node_modules/@waterplane/shared/dist/pluginmaturity.js.map +1 -1
  21. package/node_modules/@waterplane/shared/dist/slash.js +13 -0
  22. package/node_modules/@waterplane/shared/dist/slash.js.map +1 -1
  23. package/node_modules/@waterplane/shared/dist/wire/sent.d.ts +7 -1
  24. package/npm-shrinkwrap.json +2 -177
  25. package/package.json +1 -2
  26. package/packages/extensions/android-apps/waterplane-plugin.json +2 -0
  27. package/packages/extensions/copilot-usage/waterplane-plugin.json +3 -0
  28. package/packages/extensions/desktop/waterplane-plugin.json +2 -0
  29. package/packages/extensions/ocr/waterplane-plugin.json +2 -0
  30. package/packages/extensions/requirements/waterplane-plugin.json +6 -0
  31. package/packages/extensions/scorecard/waterplane-plugin.json +5 -0
  32. package/packages/extensions/token-stats/waterplane-plugin.json +6 -0
  33. package/packages/host/dist/clisuggest.d.ts +2 -1
  34. package/packages/host/dist/clisuggest.js +22 -4
  35. package/packages/host/dist/clisuggest.js.map +1 -1
  36. package/packages/host/dist/deliver/deliverreturn.js +38 -1
  37. package/packages/host/dist/deliver/deliverreturn.js.map +1 -1
  38. package/packages/host/dist/deliver/deliverscreen.d.ts +13 -0
  39. package/packages/host/dist/deliver/deliverscreen.js +20 -0
  40. package/packages/host/dist/deliver/deliverscreen.js.map +1 -1
  41. package/packages/host/dist/plugins/builtin.d.ts +5 -0
  42. package/packages/host/dist/plugins/builtin.js +12 -0
  43. package/packages/host/dist/plugins/builtin.js.map +1 -1
  44. package/packages/host/dist/plugins/pluginapi.js +13 -1
  45. package/packages/host/dist/plugins/pluginapi.js.map +1 -1
  46. package/packages/host/dist/plugins/pluginregistry.d.ts +8 -0
  47. package/packages/host/dist/plugins/pluginregistry.js +12 -5
  48. package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
  49. package/packages/host/dist/push.d.ts +3 -2
  50. package/packages/host/dist/push.js +7 -6
  51. package/packages/host/dist/push.js.map +1 -1
  52. package/packages/host/dist/pushsend.d.ts +57 -0
  53. package/packages/host/dist/pushsend.js +146 -0
  54. package/packages/host/dist/pushsend.js.map +1 -0
  55. package/packages/host/dist/question.js +9 -0
  56. package/packages/host/dist/question.js.map +1 -1
  57. package/packages/host/dist/render.js +2 -1
  58. package/packages/host/dist/render.js.map +1 -1
  59. package/packages/host/dist/screenlog.d.ts +5 -0
  60. package/packages/host/dist/screenlog.js +7 -2
  61. package/packages/host/dist/screenlog.js.map +1 -1
  62. package/packages/host/dist/server/plugins.js +2 -0
  63. package/packages/host/dist/server/plugins.js.map +1 -1
  64. package/packages/host/dist/sessions/askwatch.js +1 -1
  65. package/packages/host/dist/sessions/askwatch.js.map +1 -1
  66. package/packages/host/package.json +0 -2
  67. package/packages/web/dist/assets/{demo-iVbE6mpm.js → demo-ad3g3HDa.js} +1 -1
  68. package/packages/web/dist/assets/hero.png +0 -0
  69. package/packages/web/dist/assets/index-BllRvpkw.js +170 -0
  70. package/packages/web/dist/assets/index-DUiYLEts.css +1 -0
  71. package/packages/web/dist/assets/{shared-CsHyApn-.js → shared-DBaaGttP.js} +4 -4
  72. package/packages/web/dist/assets/sheets-CrfRr6Um.js +256 -0
  73. package/packages/web/dist/build-id.txt +1 -1
  74. package/packages/web/dist/buildstamp.json +1 -1
  75. package/packages/web/dist/index.html +5 -5
  76. package/packages/web/dist/sw.js +2 -2
  77. package/release.json +3 -3
  78. package/packages/web/dist/assets/index-DiZysQBa.css +0 -1
  79. package/packages/web/dist/assets/index-NA--kuEl.js +0 -170
  80. package/packages/web/dist/assets/sheets-CDv71Sfe.js +0 -256
package/SECURITY.md CHANGED
@@ -1,580 +1,593 @@
1
1
  # Security Policy
2
2
 
3
+ waterplane lets a phone drive the real `claude` CLI, and shell commands, on your desktop. This
4
+ page says how to report a problem, where the security boundary is, what counts as a vulnerability,
5
+ and what each protection does and does not stop.
6
+
3
7
  ## Supported versions
4
8
 
5
- This is a solo/small open-source project with no release branches — only the
6
- latest commit on `main` is supported. If you find a problem, check whether
7
- it's already fixed on `main` before reporting.
9
+ This is a small open-source project with no release branches. Only the latest commit on `main` is
10
+ supported. Before reporting, check whether the problem is already fixed there.
8
11
 
9
12
  ## Reporting a vulnerability
10
13
 
11
- Please report privately, not in a public issue.
12
-
13
- Open a [GitHub private security advisory](../../security/advisories/new)
14
- for this repository. That gives us a private channel to discuss and fix
15
- the issue before any public disclosure.
14
+ Report privately, not in a public issue. Open a
15
+ [GitHub private security advisory](../../security/advisories/new) for this repository. That gives us
16
+ a private channel to discuss and fix the issue before any public disclosure.
16
17
 
17
- Include what you'd include in any good report: the affected file(s)/commit,
18
- steps to reproduce, and what you'd expect to happen instead. A PoC is
19
- appreciated but not required.
18
+ Include the affected file(s) or commit, steps to reproduce, and what you expected instead. A
19
+ proof of concept helps but is not required.
20
20
 
21
21
  ### What to expect
22
22
 
23
- This is maintained by one person, unpaid, alongside other things. There is
24
- **no bug bounty**. Realistic expectations:
23
+ One person maintains this, unpaid, alongside other work. There is **no bug bounty**.
25
24
 
26
25
  - Acknowledgment: usually within a few days.
27
- - Triage (in scope or not, and how bad): usually within a week.
28
- - Fix timeline depends on severity and my availability — could be days for
29
- something clearly in scope and serious, could be longer for anything
30
- lower-severity. I'll keep you posted rather than go silent.
31
-
32
- If you don't hear back after a reasonable wait, a follow-up comment on the advisory is fine.
33
-
34
- ## Threat model
35
-
36
- waterplane's daemon spawns the real `claude` CLI (and, for the build feature,
37
- arbitrary shell commands you've configured) in a PTY. That process can read,
38
- write, and execute anything the account running it can. **The security
39
- boundary is entirely about who can reach the daemon at all — not about
40
- sandboxing what a connected, authorized caller can do once they're in.**
41
- See `CLAUDE.md`'s "Security model" section for the full architecture; the
42
- summary:
43
-
44
- - By default the host binds only **loopback** and the machine's **Tailscale
45
- IP**. Reaching it at all normally requires being on the same tailnet as the
46
- machine — Tailscale's own device auth is the real gate. On Windows the host
47
- runs inside WSL2 with mirrored networking: its loopback is shared with
48
- Windows, so "loopback" there means any process on the Windows machine too —
49
- the same local-machine boundary as on a Mac, and the `Origin`/`Host` checks
50
- still apply to browsers on it.
51
- - `WATERPLANE_ALLOW_PUBLIC=1` additionally binds LAN interfaces. Connections
52
- classified as `lan` (see `peerKind` in `packages/host/src/net.ts`) are then
53
- required to authenticate with a password (`packages/host/src/auth.ts`):
54
- scrypt-hashed, stored under `<state dir>/auth.json`. With no password set,
55
- LAN callers are refused outright
56
- ("deny until set"). An optional `gateTailscale` toggle extends the same
57
- password requirement to Tailscale peers too, for defense in depth.
58
- Loopback is always trusted and can never be gated — it's the local
59
- management path.
60
- An unreadable `auth.json` is moved aside and treated as "no password" —
61
- still deny-until-set for LAN — and if its bytes may have held
62
- `gateTailscale`, Tailscale stays gated too until the password is set
63
- again from loopback. A corrupt file never opens the gate.
64
- - **Funnel is never trusted.** A loopback request carrying `Tailscale-Funnel-Request` (what
65
- `tailscale funnel` adds, from the public internet) is classed `public` (`requestPeerKind` in
66
- `net.ts`, used by the token gate, the host-management routes and `/api/peer*`): it always needs a
67
- token and can never manage auth or mint pairing codes. Behind `tailscale serve` every socket is
68
- loopback, so the rate limiter (`ratelimit.ts`) keys on the real client address serve writes into
69
- `X-Forwarded-For` (only when the socket is loopback and a proxy header is present), not on
70
- 127.0.0.1. `POST /api/auth/login` is rate-limited per address (429 + retry-after) before the
71
- password hash is checked.
72
- - **Device tokens.** Every token belongs to one row of the paired-device
73
- ledger, `<state dir>/devices.json` (mode 0600): 32 random bytes,
74
- base64url, returned once and kept on disk **only as its sha256**, looked up
75
- with a constant-time compare. Tokens survive a host restart; revoking a row
76
- (`POST /api/devices/revoke`, authenticated) kills its token at once and
77
- closes that device's open sockets — the close frame first, then the socket
78
- is torn down after one second whatever the peer did with it, and a frame
79
- that still arrives from it meanwhile is dropped, not dispatched
80
- (`socketAuthorized`, asked for every frame); setting, changing or clearing
81
- the password drops every row. A browser gets one from `POST /api/auth/login`
82
- as an httpOnly cookie. The Android shell (whose cross-site cookie is
83
- withheld) mints one with `POST /api/auth/token` — **only from a caller that
84
- is already trusted** (loopback, or a Tailscale peer holding a valid token
85
- when that path is gated — never LAN or Funnel, even with a token), so no password is retyped over the tailnet, and never while no
86
- password is set — and carries it as `Authorization: Bearer <tok>` on HTTP
87
- and as the WebSocket subprotocol `waterplane-tok.<tok>` on the upgrade (the host
88
- echoes only the base protocol, never the token). No token is valid while
89
- no password is set.
90
- On the phone the page keeps its tokens in the app's private localStorage; the
91
- APK's background link keeps a second copy of each gated desktop's token so it
92
- can reconnect with the app swiped away, AES-256-GCM sealed under a non-exportable
93
- Android Keystore key (`ShellLinkTokens`), bound to that desktop's id and address,
94
- sent only to that desktop's saved tailnet address, deleted when the desktop is
95
- removed, the token is refused or the link is switched off. **Stated residual:**
96
- the key needs no user authentication, because nobody is holding the phone when
97
- the service reconnects, so this protects against a copied file, not against code
98
- running as this app. That residual holds only while the phone's sign-ins are not
99
- locked: with *lock the desktop sign-ins* on (next bullet), the link keeps no copy at rest.
100
- - **Sealed sign-ins (optional, off by default).** Settings › Connection ›
101
- *lock the desktop sign-ins* (Android app) puts this phone's device tokens in one
102
- blob under a Keystore key that each use opens with the fingerprint, or on a newer
103
- shell the phone's PIN, and removes the plaintext. **It guards against a copy of
104
- the app's storage (a backup, a pulled file), not against a hand holding the
105
- unlocked phone** (that is the app lock, and the process holds the tokens in plaintext
106
- memory while running). A desktop with no password has no token, so there is nothing
107
- to lock for it. The background link, per APK: a newer app keeps each token in
108
- memory only (alerts continue after a swipe-away until the phone restarts or
109
- Android stops the app, then "locked — open the app"); an older app is handed no
110
- token, so alerts from gated desktops wait until the app is opened. Nothing on the
111
- host changes. **Stated residuals:** a token the desktop revoked stays in the blob
112
- until the next rewrap; a stray bare token that belongs to no saved desktop can stay
113
- plaintext (the row says so); passive (kids') tokens are not sealed; on an older
114
- app the start prompt is the fingerprint only, and on a plain-HTTP route that app
115
- refuses the key, so its card stays until the route is the tailnet or HTTPS (the row
116
- refuses to lock in that state, and the host setup form refuses to save a plain
117
- address while that app has both locks on); the recents thumbnail is as before; and a lost key
118
- (screen lock removed, or a new fingerprint on a fingerprint-only key) loses the
119
- sign-ins, so a tailnet-gated desktop must be paired again. A missing sensor never
120
- counts as a lost key, and a lost one's blob is set aside on the phone
121
- (`tokenSealDead`), not deleted; nothing restores it yet, and the next lock's new key
122
- makes it unreadable. A key that is dead but not worded as such by an OEM stays a
123
- refusal, and the person's way out is *forget the locked sign-ins* (confirmed, behind
124
- the app lock): it discards the blob by hand, which gives a stranger nothing. "Nothing
125
- connects before the unlock" is true of the socket only: tokenless HTTP, such as the
126
- app-update check, runs first, carrying no token.
127
- - **App lock (optional, off by default).** The Android app can require the
128
- phone's fingerprint, or on a newer shell its PIN, before it shows its screen
129
- (Settings › Connection › *lock this app*). **It is a hold-the-phone defence at
130
- UI level and nothing more.** No host route, token, gate or wire message changes;
131
- deny-until-set, token rotation, the passive allowlist and the origin checks are
132
- untouched, and **the device token's storage is unchanged** unless *lock the desktop
133
- sign-ins* is on (localStorage, plus the sealed copy above). The page stays mounted behind an opaque card. Stated
134
- residuals: anyone who has the phone's PIN passes it (the PIN fallback, or by
135
- enrolling a finger); the recents thumbnail, and the frame the WebView shows on
136
- wake before the page's JS runs, still show the last screen. The lock fails
137
- closed: a cancelled, failed, locked-out or unrecognised refusal never opens the
138
- app, at any repeat count. It opens and switches itself off (saying so) only when
139
- the phone's own credential had to change the key, the app's saved check is
140
- unreadable, or a fresh status read confirms no sensor or nothing to check with.
141
- So on an old APK with a dead sensor that still reports hardware, or on a plain
142
- HTTP route (where the old APK's key use is refused), the way out is a newer APK,
143
- another route (the tailnet or HTTPS), or clearing the app's data.
144
- - **LAN identity.** `<state dir>/lan-identity.json` (0600) holds a `hostId`
145
- and a secret. `GET /api/lan/identity` hands them out to loopback and
146
- Tailscale callers ONLY — refused (403) for every other peer kind before the
147
- token gate, so a LAN token never unlocks it. `POST /api/lan/verify {nonce}`
148
- answers anyone (pre-auth, rate-limited per remote IP) with
149
- `HMAC-SHA256(secret, hostId + "\n" + nonce)`, and the phone sends a token
150
- over LAN only after that MAC checks out. **Stated residual:** this proves
151
- the thing answering at a LAN address holds the secret; it cannot resist an
152
- **active relay** on the person's own Wi-Fi that forwards the probe to the
153
- real desktop and then reads the plain-HTTP traffic (the token included).
154
- With the token that relay can drive the desktop. It could also answer the app
155
- with a forged page. So the Android app asks of every route whether it is
156
- *protected* (`isPlainRoute` in shared, mirrored natively by `ShellRoute.java`),
157
- and on one that is not it refuses every fingerprint use, every over-the-air
158
- update and every app download or install, and warns in the address form, the
159
- health sheet, the strip ("LAN, not protected") and a toast.
160
- **The protected-route rule.** A route is protected only if it is exactly one
161
- of: loopback; a Tailscale address or MagicDNS name over `http`, or `https` to a
162
- `*.ts.net` name, unless a newer app reports that the phone has no tailnet
163
- (then a tailnet name proves nothing: with Tailscale off, `100.x` goes to the
164
- Wi-Fi gateway); the desktop's own TLS address on a newer app that holds its
165
- pin; or an `https` address the person typed themselves. Everything else is
166
- plain, **whatever its scheme**: a relay with a public certificate for its own
167
- name passes `https` validation. A reply from a desktop that has not proved
168
- itself (anything not sealed by the pairing code, or not received over a
169
- protected route) never sets a saved desktop's address, key, pin or identity,
170
- so a relay cannot walk a row onto itself. **Stated residuals:** a row saved
171
- by an older app may already hold an address someone planted (a non-`ts.net`
172
- `https` one reads as plain and the app opens an empty field to retype it; a
173
- planted `ts.net` or `100.x` one cannot be told apart); and a person who taps
174
- "pair" on a pairing link someone else made pairs with whoever made it (the
175
- confirm says so). A browser on plain HTTP has no such defence, because the page
176
- itself is what arrives unprotected, and the token is still exposed to the
177
- relay. An older app keeps the older answer (tailnet and HTTPS only).
178
- Plain HTTP on a LAN has no defence against a man in the middle; if that
179
- network is not trusted, do not enable `WATERPLANE_ALLOW_PUBLIC`.
180
- - **LAN TLS and the pin** (`packages/host/src/lantls.ts`). Only where
181
- `WATERPLANE_ALLOW_PUBLIC=1` already binds LAN addresses, the host also
182
- listens on `WATERPLANE_TLS_PORT` (default port + 1) on those same addresses
183
- with the same handler and the same gate, so a caller there is still peer kind
184
- `lan` and needs its token. Off with `WATERPLANE_TLS=0`; never on loopback or
185
- the tailnet; nothing without the opt-in. The key is P-256 in
186
- `<state dir>/lan-tls.json` (0600), kept across restarts; the certificate is
187
- self-signed and re-issued on the same key when the addresses change, so the
188
- phone's pin (SHA-256 of the key) survives. The app stores the pin per desktop
189
- and enforces it in the WebView, the background link and the pull service; a
190
- desktop with a pin is then never reached over plain HTTP on a LAN address.
191
- A self-signed port does not serve the web app (it answers a notice page): a
192
- browser click-through would look protected and is not. With
193
- `WATERPLANE_TLS_CERT` + `WATERPLANE_TLS_KEY` (a person's own leaf from a CA;
194
- a self-signed one is read as self-signed) it serves the app, and the pin
195
- follows that leaf. **What the pin does not defend:** the
196
- WebView's handler runs only when validation fails, so a certificate valid
197
- under a CA the phone trusts would skip the pin inside the WebView (pinned
198
- addresses are private IPs and `.local` names, which public CAs do not issue,
199
- and the app trusts no user-added CA); a first install of the APK over the
200
- network (Android accepts any signature then); code already running in the
201
- page; a browser; and dropping packets. The pin is a property of a newer APK:
202
- a shipped one learns it and ignores it, and stays on plain LAN with the
203
- warnings. The way out of a pinned desktop whose TLS port is off is a
204
- confirmed "use plain HTTP for this desktop" on its row; nothing does it
205
- by itself. The spike behind this (measured on two emulator WebViews only)
206
- is in `docs/notes/protocol-and-security.md`. **None of this has been tried on
207
- a real phone yet.**
208
- - **Signed updates** (`packages/host/src/updatekey.ts`, `webbundle.ts`,
209
- `packages/web/src/updateTrust.ts`, `ShellBundleVerify.java`). Each desktop
210
- signs its web bundle's manifest with its own P-256 key
211
- (`<state dir>/update-key.json`, 0600), not a project key: people build their
212
- own bundles, and a private key shipped to every desktop is no key. The
213
- manifest carries `signed` (hostId, build id, `gen`, the file list with hashes)
214
- and `sig`. A phone learns the desktop's public key from `welcome` only when
215
- the delivering socket's address was protected (above), so a relay cannot
216
- hand it one. Once a phone holds a key, an update that is unsigned, badly
217
- signed, for another desktop or older than the last it took (`gen`, which
218
- rises when the served build id changes, so a real revert still works) is
219
- refused and nothing is written. The check runs in the page (works on a
220
- shipped APK) and again natively on a newer one, which also asks in its own
221
- dialog the first time it sees a desktop's key, a changed key, or a
222
- restored desktop; page code cannot tap it. A new key (a wiped state dir) or a
223
- lower `gen` (a restored one) waits for that answer. **What it does not do:** a
224
- signature proves the bundle came from the desktop you paired, never that
225
- its code is good. **A hostile desktop can still ship anything**, and a
226
- paired host stays fully trusted (last section). A passive (kids') phone opens
227
- no socket, so it never learns a key and takes updates as before, over a
228
- route that is protected or it would take none. A shipped APK without the
229
- native check has the page's check only, so page code that is already
230
- compromised can still persist a bundle there. A desktop that cannot read or
231
- write its key file serves the bundle unsigned (logged once): a phone that
232
- holds a key then refuses it and says to update or repair the desktop.
233
- - **Pairing with no tailnet** (`pairpake.ts`, `shared/pake.ts`). A phone
234
- pairs by CPace over ristretto255 keyed by the 8-character code, on the plain
235
- port: the phone proves it knows the code first, the host answers with a
236
- sealed reply (token, `hostId`, TLS pin, update key, LAN identity), and the
237
- seal authenticates the host. A relay gets one online guess per attempt; an
238
- impostor host answering `start` gets at most five, one per share it sends,
239
- which is all a phone will answer. A real host offers the five newest open
240
- codes, each confirm checked against all of them in constant time, so with
241
- more than five open an older code never pairs this way: its phone is told
242
- the desktop does not know that code and to show a new QR, which then works,
243
- and the attempt counts as a miss against the five offered. A session lives
244
- 60 s and is deleted on its first `finish`.
245
- 10 wrong confirms close a code *for this pairing only*: it still redeems over
246
- the tailnet, so a device on the Wi-Fi can stop an encrypted pairing (as
247
- dropping packets does) but cannot burn a code meant for the tailnet. The
248
- desktop refuses with no password set. **A code never crosses a plain socket
249
- and lives:** on one that is not loopback, the tailnet, `tailscale serve` or
250
- TLS, `/pair/<code>` and `/pair/<code>/apk` burn the code and show a page with
251
- no link and no APK; a LAN-only desktop's screen shows the deep-link QR, which
252
- goes camera to app. A code already sent plain is dead on that phone. The
253
- link also carries a 128-bit commitment to the key and pin, which the seal
254
- must match. **Stated residuals:** a person who accepts the question that
255
- appears for an older desktop (it cannot tell an old desktop from someone
256
- blocking encrypted pairing) spends that code and token in the clear, as
257
- before; a relay can fill the miss count and stop pairing; whether every
258
- camera app opens the waterplane link is unverified, and the typed code runs the
259
- same protocol; the first APK install over the network is unprotected.
260
- `waterplane pair` prints the update key's fingerprint beside the code.
261
- - **Read ticket** (`auth.ts`, `POST /api/auth/read-ticket`). An image tag or a
262
- download link cannot carry a bearer, so a phone with no tailnet could not
263
- load them. A signed device token buys a one-hour ticket (HMAC of device id,
264
- scope and expiry; the key lives in memory, so a restart voids every
265
- ticket) that is accepted as `?wt=` **only** on the two opaque read routes
266
- (`OPAQUE_READ_ROUTES`), **only** over the TLS socket, GET/HEAD only, only
267
- while the device row exists, and held to the row's scope (a passive phone's
268
- ticket gets what a passive token gets). Access logs, shares and copies
269
- strip `wt`. A ticket in a URL is a credential for those two read routes for
270
- up to an hour: do not paste such a URL.
271
- - **A phone with Tailscale off.** It reaches the desktop only on the Wi-Fi.
272
- A shipped APK stays on plain LAN with name-based trust as before, except
273
- that an update key it already holds still refuses a forged bundle. A newer
274
- APK pairs by code, holds the pin and goes over TLS. A phone that never
275
- learned a pin cannot tell a relay from the desktop on the first
276
- plain contact; pairing by code is the fix. The password-change case: tokens
277
- rotate as before, so a LAN-only phone must pair again, and its gate says so
278
- rather than asking for a password that cannot work there.
279
- On a shell that enforces pins, a page that knows Tailscale is off sends no
280
- device token to a tailnet address, pinned or not, since only the Wi-Fi can
281
- answer it. Until the shell's first network reading it holds the token back on
282
- those addresses and learns no pin, for at most 8 seconds; a shell silent past
283
- that falls to trust by name. The background link is not covered on an unpinned
284
- row: it sends its token to the address it holds, as before.
285
- - **Pairing codes** (`packages/host/src/pair.ts`). `POST /api/pair/mint`
286
- sits behind the normal gate; the 8-character code lives 10 minutes, is kept
287
- in memory only as its sha256 (a restart invalidates every outstanding one),
288
- at most 5 are live, and it is spent by its first successful
289
- `POST /api/pair/redeem` — the one pre-auth route that can mint a device
290
- token, rate-limited per remote IP (5, then one per 10 s). Redeem mints a
291
- token only while a password is set; with none, a LAN redeemer is refused
292
- before the code is spent (deny-until-set). The LAN identity secret goes only
293
- to a loopback/Tailscale redeemer. `GET /pair/<code>` (landing) and
294
- `/pair/<code>/apk` are outside `/api/` and the token gate so a brand-new
295
- phone can load them, and reveal nothing about the desktop until the code
296
- checks out; they carry `no-store`, `X-Frame-Options: DENY`, a
297
- `default-src 'none'` CSP and `no-referrer`. They are NOT in
298
- `OPAQUE_READ_ROUTES`.
299
- - **Passive devices** (the kids' phone). Pairing can mark a device
300
- "passive": its ledger row stores `scope: "passive"` (rows without it are
301
- full scope). The host holds such a token to a **default-deny allowlist**
302
- (`passive.ts`): `GET /api/health`, `/api/webbundle[/file]`, `/api/theme`,
303
- `/api/passive/state`, and `GET /api/repos/<id>/artifact` only for an APK
304
- that is in the auto-pull queue at that moment — every other `/api/` route
305
- (403) and the WebSocket (closed 4403) are refused, and `/api/auth/token`
306
- never mints it a full token. A passive pairing needs a password and
307
- switches "gate Tailscale" on; while any passive row exists the gate cannot
308
- be switched off, so the phone has no tokenless path on Tailscale or the
309
- LAN. A request that reaches loopback through `tailscale serve` (it carries
310
- a proxy header) is classed as a Tailscale peer by the gate, so the gate
311
- applies to the https address too. **Unlock:** a PIN (4–12 digits,
312
- scrypt-hashed in `<state dir>/passive-pin.json`, 0600) set only from a
313
- trusted caller that is not passive (`POST /api/auth/pin`);
314
- `POST /api/auth/unlock` checks it, rate-limited per device (5, then one per
315
- minute), and lifts that one token to full scope for 30 minutes, held in the
316
- host's memory only — expiry, "Lock now" (`POST /api/auth/lock`), a PIN
317
- change or a host restart end it, and its sockets are closed. **Auth
318
- material is the one part of full scope an unlock never grants**, because
319
- each piece would outlive the 30 minutes: an unlocked passive device still
320
- cannot mint a pairing code (`POST /api/pair/mint` — a redeemed code is a
321
- permanent full token) or a device token, revoke a device, pair or unpair a
322
- peer (`/api/peers` writes), read the LAN identity secret, or set the
323
- password, the Tailscale gate or the PIN. Three places say no: `passive.ts`'s
324
- `passiveNeverAllows` for the pairing, revoke, peer and identity routes
325
- (checked in `server/http.ts` right after the allowlist), the `/api/auth/token`
326
- and `/api/auth/pin` handlers' own scope checks, and the WebSocket
327
- `auth.set`/`auth.clear`/`auth.gateTailscale` ops (`routes/system.ts`, by the
328
- socket's device row). A passive caller that knows the PASSWORD itself can
329
- still log in like any browser — the PIN model never covered that.
330
- - Password management (`auth.set` / `auth.clear` / `gateTailscale`) is only
331
- accepted from an already-trusted connection (loopback or Tailscale). A LAN
332
- caller — even one who knows the current password — can never set or change
333
- it, only authenticate with it.
334
- - `POST /api/hook` (the sink the spawned `claude` process posts lifecycle
335
- events to), `POST /api/plugin/<id>/hook` and `POST /api/shell/open` answer
336
- only a process on this machine, regardless of any other setting: the socket
337
- peer must be loopback AND the request must carry no reverse-proxy header
338
- (`X-Forwarded-*`, `Forwarded`, `X-Real-IP`, `Via`, `Tailscale-User-*` —
339
- one list, `net.ts`'s `localProcessRefusal`). Loopback alone is not enough:
340
- behind `tailscale serve` every tailnet request arrives from 127.0.0.1.
341
- A loopback request whose `Host` names anything but this machine's loopback is
342
- treated as proxied; a reverse proxy that forwards neither a proxy header nor
343
- the client `Host` is treated as local — front the daemon only with one that
344
- sets `X-Forwarded-For`.
345
- - A shell session (a plain login shell in a repo, `backends/shell.ts`) is the
346
- same `session.create` op behind the same gates as a `claude` session and
347
- widens nothing. Its `$BROWSER` shim posts to `POST /api/shell/open` (local
348
- process only, above) and stores nothing for an unknown session; a URL with
349
- userinfo in its authority (`https://github.com@evil.tld/`) is never
350
- published. Only a person's tap opens one: a job (the intent tick, the
351
- backlog, a fan-out, an agent's post) and a peer desktop's job are refused.
352
- The shell does not inherit the daemon's API keys and key files
353
- (`*_API_KEY`, `*_KEY_FILE`, `*_KEYCHAIN` are unset in the pane), and its
354
- commands are never written to the session's prompt history.
355
- - **Live desktop** (`packages/extensions/desktop`, macOS, and Windows with the host in WSL; experimental). Watching is a
356
- stream on the same device-token socket and widens no gate. **Control is
357
- arbitrary input on the desktop** (clicks and keys reach every app, password
358
- managers and a logged-in terminal included), so it carries the same trust as
359
- the PTY and is a separate setting, **off by default**. It is gated on the
360
- host, not only on the phone: with it off, `stream.input` is dropped before it
361
- reaches the helper, and with it on a Mac still needs the macOS Accessibility
362
- grant. Windows asks no app for a grant, so there the host's switch is the only
363
- gate. A peer token never reaches a stream (`/api/peer/*` only), and no job,
364
- schedule or peer desktop opens a desktop stream. The helper `wp-desktop` runs as the desktop user and capture runs only while a
365
- view is open. On a Mac it is signed with a stable identifier and holds only the
366
- Screen Recording and Accessibility grants a person gave it there (it asks macOS
367
- only when a person taps Fix on a setup row), and macOS shows its own recording
368
- indicator the whole time. Windows shows no such indicator.
369
- - **Browser origins** (`packages/host/src/origin.ts`). Every `/api/*` request
370
- and WebSocket upgrade is checked on `Origin` and `Host`; only `http`/`https`
371
- origins naming this machine on a port it serves pass, plus exactly one
372
- non-web origin: `capacitor://localhost`, the iOS shell's page (WKWebView
373
- cannot serve an app from `http`, so it is the twin of the Android shell's
374
- `http://localhost`). Any other scheme is refused, and the CORS header only
375
- ever reflects an origin that passed — never `*`. `*.ts.net` is accepted by
376
- suffix only while on a tailnet whose MagicDNS name could not be read, only
377
- for requests arriving through `tailscale serve`, and only when the Origin
378
- names the same host the request was sent to; the name is re-read
379
- until known.
380
- - `/api/agent/intents`, `/api/agent/backlog`, `/api/agent/followup` and `/api/agent/exposure[/triage]` (an agent in a driven repo
381
- posting work for later, `agentintents.ts`; marking a crash / exposure finding, `agentexposure.ts`: a read, and a mark that needs a live session, never a clear) answers only a loopback caller that is neither a browser
382
- (any `Origin` or `Sec-Fetch-Site` header is refused, same-origin included)
383
- nor proxied (`tailscale serve` forwards to 127.0.0.1, so the same
384
- reverse-proxy headers as the hook sinks above are refused). It adds no new power:
385
- a posted row goes through the same containment gate and the same store as
386
- the phone's `intent.post`, cannot bring a budget waiver, and waits for the
387
- person's auto-fire switch and budget like any scheduled item; a backlog add
388
- never starts at all until the person fires it.
389
- - **Consent is the phone's, checked on the host** (`confirmgate.ts`). The
390
- checklist fixes that act on the person's behalf (`CONFIRM_GATED_FIXES`:
391
- accepting Google's Android SDK licence and downloading the toolchain, making
392
- a signing key, writing a repo's Claude settings) and the first-app job
393
- (`POST /api/onboard`, and `/api/onboard/resume` when it adds consent) need
394
- BOTH a browser `Origin` that passes the origin gate (an Origin-less request,
395
- allowed everywhere else, is refused here) AND a one-shot token, 15 minutes,
396
- minted by the GET that draws the confirm (the compliance report, the onboard
397
- plan, the stopped job) and bound to that worktree + fix id, that path + the
398
- consent ids the screen listed, or that job. A gated fix never runs inside a
399
- "Fix all" list. Honest scope: a process running as you can run `sdkmanager`
400
- itself, so this does not stop a determined local program; it stops the
401
- spawned `claude` agent, a plugin or a script from accepting a licence with
402
- one undocumented POST, and every refusal is logged.
403
- - **Ask rules** (`packages/host/src/askrules.ts`). A desktop-wide list of
404
- permission rules (`Bash(git push * main)`, `npm publish*`, ...) is passed to
405
- every spawned `claude` as `permissions.ask` in the `--settings` flag, which
406
- outranks anything the agent writes into the repo's settings and holds under
407
- bypass-permissions; the resulting box is answerable only by a person's tap.
408
- Changing the list (`PUT /api/askrules`) needs a browser `Origin` and a
409
- one-shot confirm minted by the GET for the list's current revision, so a
410
- `curl` with a forged header cannot delete a rule and a stale phone cannot
411
- overwrite a newer list. It reaches `claude` sessions started on this
412
- desktop only: a peer desktop's jobs run under that peer's own list, and the
413
- host's own headless calls (review, assess) take no rule. The state file is
414
- read at boot, so a same-user edit of it takes effect at the next restart.
415
- Honest scope: it stops the accident, not a wrapper script the rule cannot
416
- see or an agent talking you into Yes.
417
- - **Toolchain downloads** land in a fresh `mkdtemp` directory (0700) under the
418
- per-user data dir, are created with `O_EXCL`, and are hashed and unpacked
419
- from there, never from a shared temp dir another local account could plant
420
- a file or symlink in.
421
- - **Files the host rewrites inside a repo** — `.gitignore` (the secret writer,
422
- the checklist's gitignore fix, the signing-key link), `CLAUDE.md` and
423
- `.claude/settings.json` (checklist fixes) — are written through an in-repo
424
- symlink but never through one that leaves the worktree: a committed
425
- `.gitignore -> ~/.gitconfig` is refused in words (`writeTextAtomicKeepMode`'s
426
- `within`), the same realpath rule the file routes apply to reads. The repo
427
- walks (tree search, artifact discovery, directory entry counts) neither
428
- descend into nor list the contents of a link whose target is outside the
429
- worktree (the link itself is shown as a link, nothing more).
430
- - **A repo's own repo-health engine** (`tools/repohealth.mjs`, run by the host
431
- after one approval — `repohealthapproval.ts`) is pinned by a hash of the
432
- entry, its siblings and every module it imports with a plain relative path,
433
- static or dynamic. An engine whose loads the hash cannot see is never
434
- approvable and the phone is told why: an `import()`/`require()` argument that
435
- is not one plain string, `eval`, `Function()`, `vm`, a worker, loader hooks, a
436
- `#` import-map entry, a module from a URL or an absolute path, an import
437
- outside the repo, a dependency committed under any `node_modules` or reached
438
- by the repo's own package name, or a tree too big to read whole. Honest scope:
439
- a comment- and string-aware text scan, not a parser — it closes the plain
440
- shapes; an engine that spawns `node <file>` or reaches code through an alias
441
- the scan does not know still runs what the approval never saw.
442
- - **Plugins** (`packages/host/src/plugins/`, `docs/notes/plugins.md`) are code
443
- the machine's owner put in their own config dir (`~/.config/waterplane/plugins/`
444
- or `plugin-paths.json`). They run in the daemon's process with its rights —
445
- the same trust as the daemon, crossing no boundary it has not. The phone
446
- never runs plugin code: it draws what a plugin declares. A plugin answers a
447
- choice box only through the core's answer gate (`answergate.ts`: the
448
- manifest's `answers`, a per-session switch default OFF that only the phone
449
- flips, the same box it was told about, never a trust/tool/plan box or one
450
- naming something destructive or secret), reads files only through the
451
- containment guard, and — when loaded from outside this repo — reads only
452
- the env names its manifest declares (the in-repo extensions under
453
- `packages/extensions/` are part of the host and read its own knobs directly).
454
- Nothing on the network can install, load or reload one.
455
- - **Peer tokens (paired desktops).** Two waterplane hosts can be paired so one
456
- delegates a job to the other (`<state dir>/peers.json`, mode 0600;
457
- outbound rows keep the token, inbound rows only its sha256). A peer token is a
458
- scoped credential: it unlocks `/api/peer/*` and nothing else, and a device
459
- token never unlocks those routes. Managing pairings (`/api/peers`) is allowed
460
- on the trusted path only (loopback and Tailscale, never LAN), the same rule as
461
- the password. `/api/peer/*` is exempt from the device-token gate in
462
- `server/http.ts` because `peers/routes.ts` applies a stricter check: trusted
463
- path kind, then the peer bearer token, then the peer API version header; an
464
- unknown token gets "not paired" and the refusal is logged with the remote
465
- address. Changing the password does NOT rotate peer tokens (a separate ledger;
466
- unpair to revoke). Peer addresses are tailnet or loopback only (100.64/10,
467
- `*.ts.net`, 127.0.0.1), never `*.local`. Honest scope: a tailnet client that
468
- sends no `Origin` can already drive a host over its WebSocket, so pairing gives
469
- a peer no power a tailnet process lacks. It makes delegation named (the
470
- "from peer" mark can be trusted) and refusable, not a new privilege class. A
471
- delegated job runs as an ordinary backlog row on the peer, held by that
472
- owner's auto-fire switch and budget, in a detached worktree
473
- (`<clone>/.claude/worktrees/peer-<from>-<key>`), and refuses to restart or install
474
- the peer's running daemon (the marker is checked by `scripts/restart.sh` and
475
- `deploy/install-service.sh`). `GET /api/peers` lists the peers from loopback or
476
- Tailscale only and reports this daemon's own tailnet address, never a LAN one.
477
- - **The installer** (`curl -fsSL https://github.com/waterplane-dev/waterplane/releases/latest/download/install.sh | sh`,
478
- generated from `deploy/install.sh.tmpl`) is code you run as yourself, so
479
- read it first if you prefer: download it, open it, then `sh install.sh`.
480
- It edits no shell rc file and runs at most one `sudo` line — the Linux
481
- package manager installing `tmux` — and only after asking on the terminal;
482
- everything else is unprivileged. It verifies the Node runtime
483
- against nodejs.org's `SHASUMS256.txt` and the release archive against the
484
- `.sha256` published beside it, and installs nothing on a mismatch. Those
485
- checksums come over the same HTTPS channel as the files, so on their own
486
- they catch only corruption. **Who published the release** is checked too:
487
- the `.sha256` carries an Ed25519 signature (`.sha256.sig`) made with the
488
- project's offline release key, and the installer verifies it — with the
489
- Node it has just checked against nodejs.org — against the public key
490
- written into the script itself (`deploy/release-pubkey.pem`). A missing or
491
- wrong signature installs nothing. That defeats a compromised release page,
492
- GitHub account or mirror that swaps the archive and its checksum together.
493
- It does not defeat a compromised signing key, nor an `install.sh` that was
494
- itself swapped (the key travels inside it — read it, or compare it with the
495
- one in the repository). Rotating the key means a new `install.sh`: copies
496
- already downloaded trust only the old key. `WATERPLANE_INSECURE_SKIP_SIG=1`
497
- skips the check, loudly, for a release you built yourself. **Honest scope,
498
- freshness:** the signature proves who published a release, not that it is
499
- the newest — whoever can write the release assets can keep offering an
500
- OLDER signed release as "latest" (a freeze), though never one below the
501
- version already installed, which `waterplane update` refuses. A signed
502
- expiry would close that and needs re-signing on a schedule; until then a
503
- "current" that stays put for long is worth a glance at the releases page.
504
- `waterplane update` holds a release to the same rule with the same key,
505
- embedded in the host (`host/cli/releasesig.ts`, test-pinned to
506
- `deploy/release-pubkey.pem`): it verifies `release.json.sig` before it
507
- reads `release.json` (which decides the version it fetches) and
508
- `.sha256.sig` before it trusts the `.sha256`, and a missing or wrong
509
- signature refuses the update — nothing is installed and the current version
510
- is untouched. The same `WATERPLANE_INSECURE_SKIP_SIG=1` skips it, with a
511
- warning on every run; the doctor's self-update row believes a newer release
512
- only after the same check. The
513
- login service it installs (`waterplane service install`) bakes in only PATH,
514
- HOME, its own state dir (and the root and Claude config dir when the shell
515
- named them) and the variables passed with `--env`, never the installing
516
- shell's environment, and refuses `WATERPLANE_ALLOW_PUBLIC` outright: a public bind
517
- stays an explicit, visible opt-in in `.env`.
518
- - **`node-pty` is pinned to a pre-release (`1.2.0-beta.15`) on purpose.** The
519
- stable 1.1.0 leaks two file descriptors into the daemon on every macOS spawn
520
- (a spare `/dev/ptmx` and the child's slave), which exhausted the machine's
521
- pseudo-terminals; the 1.2.0 betas fix it. The pin is exact, the published
522
- package's `npm-shrinkwrap.json` holds it, and `ptyleak.test.mjs` guards the fix.
523
- Scanners that flag the pre-release or node-pty's install script (a native
524
- build fallback; prebuilt binaries ship in the package) are seeing this choice.
525
- It moves to `1.2.0` once that is released stable.
26
+ - Triage (in scope or not, and how serious): usually within a week.
27
+ - Fix: depends on severity and availability. Days for something serious and clearly in scope,
28
+ longer for lower severity. You will be kept posted rather than left in silence.
29
+
30
+ If you hear nothing after a reasonable wait, a follow-up comment on the advisory is fine.
31
+
32
+ ### How a report is handled
33
+
34
+ 1. The report stays in the private advisory while it is confirmed and triaged.
35
+ 2. The fix is made and tested, and goes out in a release.
36
+ 3. The advisory is published, and the release's `CHANGELOG.md` entry names the fix under
37
+ **Security:**, with the CVE or GHSA id when there is one.
38
+ 4. Reporters are credited by name in the advisory and the changelog, unless they ask to stay
39
+ anonymous.
40
+
41
+ The [assurance case](docs/notes/assurance-case.md) argues why these properties hold. To check a
42
+ download yourself, see [Verify a download by hand](docs/install.md#verify-a-download-by-hand).
43
+
44
+ ## The boundary, in plain words
45
+
46
+ The daemon spawns the real `claude` CLI (and, for the build feature, shell commands you configured) in a
47
+ PTY, a pseudo-terminal. That process can read, write and run anything the account running it can. **The
48
+ security boundary is who can reach the daemon at all. It is not a sandbox around what a connected,
49
+ authorized caller can do once inside.**
50
+
51
+ - By default the host binds only **loopback** and the machine's **Tailscale IP**. Reaching it normally
52
+ means being on the same tailnet, so Tailscale's device authentication is the real gate.
53
+ - On Windows the host runs inside WSL2 with mirrored networking. Its loopback is shared with Windows, so
54
+ "loopback" there means any process on the Windows machine, the same local-machine boundary as on a
55
+ Mac. The `Origin`/`Host` checks still apply to browsers on it.
56
+ - Never bind a public interface. `WATERPLANE_ALLOW_PUBLIC=1` adds LAN interfaces behind a password gate
57
+ (below) and is an explicit opt-in. Plain HTTP on a LAN has no defence against a man in the middle: if
58
+ that network is not trusted, do not enable it.
526
59
 
527
60
  ### In scope
528
61
 
529
- Bugs that let someone who should **not** be able to reach or drive the daemon
530
- do so anyway, e.g.:
62
+ Bugs that let someone who should **not** be able to reach or drive the daemon do so anyway:
531
63
 
532
- - Auth bypass: reaching a gated (LAN or `gateTailscale`) daemon without a
533
- valid password/token, or forging/replaying/extending a token.
534
- - Cross-origin or DNS-rebinding attacks that let a malicious web page drive
535
- the API/WebSocket from a browser already on the trusted network.
536
- - Path traversal in the file/git endpoints (`packages/host/src/files.ts`,
64
+ - Auth bypass: reaching a gated (LAN or `gateTailscale`) daemon without a valid password or token, or
65
+ forging, replaying or extending a token.
66
+ - Cross-origin or DNS-rebinding attacks that let a malicious web page drive the API or WebSocket from a
67
+ browser already on the trusted network.
68
+ - Path traversal in the file and git endpoints (`packages/host/src/files.ts`,
537
69
  `packages/host/src/git.ts`) that reaches outside the intended repo root.
538
- - Injection bugs (shell, argument, or otherwise) reachable from network
539
- input rather than from the operator's own configured commands.
540
- - Anything that lets a LAN-classified caller manage auth (set/clear the
541
- password, toggle `gateTailscale`) — that's supposed to be impossible.
542
- - Secrets (VAPID keys, auth password hash, tokens, `<state dir>/*.json`) leaking
543
- to a caller who shouldn't be able to read them.
544
-
545
- ### Out of scope (by design)
546
-
547
- - **Anything an authorized, already-connected caller can do.** The whole
548
- point of this project is that a trusted device can drive `claude` and run
549
- configured build commands — both of which execute arbitrary code by
550
- design. An authorized caller can read/write files in scanned repos, run
551
- git commands including `push`, run saved build commands
552
- (`packages/host/src/builds.ts`), and — via whatever `claude` itself can
553
- do — read the screen, use the clipboard, or run anything else the desktop
554
- user could run themselves. None of that is a vulnerability; it's the
555
- product.
556
- - **Exposing the daemon to an untrusted network is a misconfiguration, not a
557
- vulnerability.** `WATERPLANE_ALLOW_PUBLIC=1` and/or binding `WATERPLANE_HOST` to a
558
- public interface are explicit opt-ins with a loud warning in the README.
559
- If you do that and then something bad happens because a stranger reached
560
- the daemon, that's expected behavior working as documented, not a security
561
- bug to report here.
562
- - Denial of service from a caller who is already authorized to reach the
563
- host (e.g. spamming sessions, filling disk with uploads) — annoying, but
564
- not a security boundary violation. Feel free to file it as a regular
565
- issue/PR if you have a fix in mind.
566
- - Vulnerabilities in the `claude` CLI itself, in Tailscale, or in other
567
- third-party software this project invokes but doesn't ship. Report those
568
- upstream.
569
- - Anything that requires physical access to an already-unlocked machine
570
- running the daemon.
571
- - **A paired host is fully trusted by the Android shell.** The app's UI
572
- updates over the air from the host it is paired with
573
- (`packages/web/src/webUpdate.ts`). Its bundles are signed with a per-desktop
574
- key the phone pins over a protected route, which stops a relay on the network,
575
- never the desktop itself: a host you pair with can ship JS that runs with the
576
- shell's native plugins. Pair only with desktops you control.
577
-
578
- If you're not sure whether something is in scope, report it privately anyway
579
- and let us sort it out together — a false positive costs us a few minutes; a
580
- missed real bug costs a lot more.
70
+ - Injection (shell, argument or otherwise) reachable from network input rather than from the operator's
71
+ own configured commands.
72
+ - Anything that lets a LAN-classified caller manage auth (set or clear the password, toggle
73
+ `gateTailscale`). That is meant to be impossible.
74
+ - Secrets (VAPID keys, the password hash, tokens, `<state dir>/*.json`) leaking to a caller who should
75
+ not read them.
76
+
77
+ ### Out of scope
78
+
79
+ - **Anything an authorized, already-connected caller can do.** A trusted device can drive `claude` and
80
+ run configured build commands, and both execute arbitrary code by design. Such a caller can read and
81
+ write files in scanned repos, run git including `push`, run saved build commands
82
+ (`packages/host/src/builds.ts`) and, through `claude`, read the screen, use the clipboard or run
83
+ anything the desktop user could. None of that is a vulnerability.
84
+ - **Exposing the daemon to an untrusted network** with `WATERPLANE_ALLOW_PUBLIC=1` or by binding
85
+ `WATERPLANE_HOST` to a public interface. These are explicit opt-ins with a loud warning in the README;
86
+ a stranger reaching the daemon after that is expected behavior.
87
+ - Denial of service from a caller who is already authorized (spamming sessions, filling disk with
88
+ uploads). File it as a regular issue or PR if you have a fix.
89
+ - Vulnerabilities in the `claude` CLI, Tailscale or other third-party software this project invokes but
90
+ does not ship. Report those upstream.
91
+ - Anything that needs physical access to an already-unlocked machine running the daemon.
92
+ - **A paired host is fully trusted by the Android shell.** The app's UI updates over the air from the
93
+ host it is paired with (`packages/web/src/webUpdate.ts`). Bundles are signed with a per-desktop key the
94
+ phone pins over a protected route. That stops a relay on the network, never the desktop itself: a host
95
+ you pair with can ship JS that runs with the shell's native plugins. Pair only with desktops you
96
+ control.
97
+
98
+ If you are unsure whether something is in scope, report it privately anyway. A false positive costs a
99
+ few minutes; a missed real bug costs far more.
100
+
101
+ ## How access is controlled
102
+
103
+ ### Network and password gate
104
+
105
+ - `WATERPLANE_ALLOW_PUBLIC=1` also binds LAN interfaces. Connections classified `lan` (`peerKind` in
106
+ `packages/host/src/net.ts`) must then authenticate with a password (`packages/host/src/auth.ts`),
107
+ scrypt-hashed in `<state dir>/auth.json`. With no password set, LAN callers are refused outright
108
+ ("deny until set"). The optional `gateTailscale` toggle extends the requirement to Tailscale peers.
109
+ Loopback is always trusted and can never be gated.
110
+ - An unreadable `auth.json` is moved aside and treated as "no password", still deny-until-set for LAN.
111
+ If its bytes may have held `gateTailscale`, Tailscale stays gated until the password is set again
112
+ from loopback. A corrupt file never opens the gate.
113
+ - Password management (`auth.set`, `auth.clear`, `gateTailscale`) is accepted only from an
114
+ already-trusted connection (loopback or Tailscale). A LAN caller, even one who knows the password,
115
+ can authenticate with it but never set or change it.
116
+ - **Funnel is never trusted.** A loopback request carrying `Tailscale-Funnel-Request` (added by
117
+ `tailscale funnel`, from the public internet) is classed `public` (`requestPeerKind` in `net.ts`,
118
+ used by the token gate, the host-management routes and `/api/peer*`). It always needs a token and
119
+ can never manage auth or mint pairing codes.
120
+ - Behind `tailscale serve` every socket is loopback and carries a proxy header. The gate classes such
121
+ a request as a Tailscale peer, so it applies to the https address too, and the rate limiter
122
+ (`ratelimit.ts`) keys on the client address serve writes into `X-Forwarded-For` (read only when the
123
+ socket is loopback and a proxy header is present). `POST /api/auth/login` is rate-limited per
124
+ address (429 with retry-after) before the password hash is checked.
125
+
126
+ ### Device tokens
127
+
128
+ Every token belongs to one row of the paired-device ledger, `<state dir>/devices.json` (mode 0600).
129
+ A token is 32 random bytes, base64url, returned once and stored **only as its sha256**, compared in
130
+ constant time. Tokens survive a host restart. No token is valid while no password is set.
131
+
132
+ - Revoking a row (`POST /api/devices/revoke`, authenticated) kills its token at once and closes that
133
+ device's sockets: close frame first, teardown after one second whatever the peer does, and a frame
134
+ arriving meanwhile is dropped (`socketAuthorized`, asked for every frame). Setting, changing or
135
+ clearing the password drops every row.
136
+ - A browser gets a token from `POST /api/auth/login` as an httpOnly cookie. The Android shell, whose
137
+ cross-site cookie is withheld, mints one with `POST /api/auth/token`, **only from a caller that is
138
+ already trusted**: loopback, or a Tailscale peer holding a valid token when that path is gated;
139
+ never LAN or Funnel, even with a token, and never while no password is set. It sends the token as
140
+ `Authorization: Bearer <tok>` on HTTP and as the WebSocket subprotocol `waterplane-tok.<tok>` on the
141
+ upgrade. The host echoes only the base protocol, never the token.
142
+ - The page keeps its tokens in the app's private localStorage. The APK's background link keeps a
143
+ second copy of each gated desktop's token so it can reconnect with the app swiped away: AES-256-GCM
144
+ sealed under a non-exportable Android Keystore key (`ShellLinkTokens`), bound to that desktop's id
145
+ and address, sent only to its saved tailnet address, and deleted when the desktop is removed, the
146
+ token is refused or the link is switched off. **Residual:** the key needs no user authentication,
147
+ because nobody is holding the phone when the service reconnects. It protects against a copied file,
148
+ not against code running as this app. With *lock the desktop sign-ins* on (see The Android app) the
149
+ link keeps no copy at rest.
150
+
151
+ ### Pairing codes
152
+
153
+ (`packages/host/src/pair.ts`.) `POST /api/pair/mint` sits behind the normal gate. The 8-character code
154
+ lives 10 minutes and is kept in memory only as its sha256, so a restart invalidates every outstanding
155
+ one. At most 5 are live. The first successful `POST /api/pair/redeem` spends it. That is the one
156
+ pre-auth route that can mint a device token, and it is rate-limited per remote IP (5, then one per
157
+ 10 s). Redeem mints a token only while a password is set; with none, a LAN redeemer is refused before
158
+ the code is spent. The LAN identity secret goes only to a loopback or Tailscale redeemer.
159
+
160
+ `GET /pair/<code>` (landing) and `/pair/<code>/apk` sit outside `/api/` and the token gate so a new
161
+ phone can load them. They reveal nothing about the desktop until the code checks out, carry
162
+ `no-store`, `X-Frame-Options: DENY`, a `default-src 'none'` CSP and `no-referrer`, and are not in
163
+ `OPAQUE_READ_ROUTES`.
164
+
165
+ ### Pairing with no tailnet
166
+
167
+ (`pairpake.ts`, `shared/pake.ts`.) A phone pairs by CPace over ristretto255, keyed by the 8-character
168
+ code, on the plain port. The phone proves it knows the code first; the host answers with a sealed
169
+ reply (token, `hostId`, TLS pin, update key, LAN identity), and the seal authenticates the host.
170
+
171
+ - A relay gets one online guess per attempt. An impostor host answering `start` gets at most five, one
172
+ per share it sends, which is all a phone will answer.
173
+ - A real host offers the five newest open codes and checks each confirm against all of them in
174
+ constant time. With more than five open, an older code never pairs this way: its phone is told the
175
+ desktop does not know that code and to show a new QR, which then works. The attempt counts as a miss
176
+ against the five offered.
177
+ - A session lives 60 s and is deleted on its first `finish`. Ten wrong confirms close a code *for this
178
+ pairing only*; it still redeems over the tailnet. So a device on the Wi-Fi can stop an encrypted
179
+ pairing (as dropping packets does) but cannot burn a code meant for the tailnet.
180
+ - The desktop refuses with no password set.
181
+ - **A code never crosses a plain socket and lives.** On a socket that is not loopback, the tailnet,
182
+ `tailscale serve` or TLS, `/pair/<code>` and `/pair/<code>/apk` burn the code and show a page with
183
+ no link and no APK. A LAN-only desktop's screen shows the deep-link QR, which goes camera to app. A
184
+ code already sent in the clear is dead on that phone.
185
+ - The link also carries a 128-bit commitment to the key and pin, which the seal must match.
186
+ `waterplane pair` prints the update key's fingerprint beside the code.
187
+
188
+ **Residuals:** a person who accepts the question shown for an older desktop spends that code and token
189
+ in the clear (the app cannot tell an old desktop from someone blocking encrypted pairing). A relay can
190
+ fill the miss count and stop pairing. Whether every camera app opens the waterplane link is
191
+ unverified; the typed code runs the same protocol. The first APK install over the network is
192
+ unprotected.
193
+
194
+ **A phone with Tailscale off** reaches the desktop only on the Wi-Fi. An APK built before code pairing
195
+ stays on plain LAN with trust by name, except that an update key it already holds still refuses a
196
+ forged bundle. A current APK pairs by code, holds the pin and goes over TLS. A phone that never learned
197
+ a pin cannot tell a relay from the desktop on the first plain contact; pairing by code is the fix.
198
+ After a password change tokens rotate, so a LAN-only phone must pair again, and its gate says so rather
199
+ than asking for a password that cannot work there. On a shell that enforces pins, a page that knows
200
+ Tailscale is off sends no device token to a tailnet address, pinned or not, since only the Wi-Fi can
201
+ answer it. Until the shell's first network reading it holds the token back on those addresses and
202
+ learns no pin, for at most 8 seconds; a shell silent past that falls back to trust by name. The
203
+ background link is not covered on an unpinned row: it sends its token to the address it holds.
204
+
205
+ ### Passive devices (a kids' phone)
206
+
207
+ Pairing can mark a device "passive": its ledger row stores `scope: "passive"` (rows without it are full
208
+ scope). The host holds such a token to a **default-deny allowlist** (`passive.ts`): `GET /api/health`,
209
+ `/api/webbundle[/file]`, `/api/theme`, `/api/passive/state`, and `GET /api/repos/<id>/artifact` only
210
+ for an APK in the auto-pull queue at that moment. Every other `/api/` route is refused (403), the
211
+ WebSocket is closed (4403), and `/api/auth/token` never mints it a full token. A passive pairing needs
212
+ a password and switches "gate Tailscale" on; while any passive row exists the gate cannot be switched
213
+ off, so the phone has no tokenless path on Tailscale or the LAN.
214
+
215
+ **Unlock.** A PIN (4 to 12 digits, scrypt-hashed in `<state dir>/passive-pin.json`, 0600) is set only
216
+ from a trusted caller that is not passive (`POST /api/auth/pin`). `POST /api/auth/unlock` checks it,
217
+ rate-limited per device (5, then one per minute), and lifts that one token to full scope for 30
218
+ minutes, held in host memory only. Expiry, "Lock now" (`POST /api/auth/lock`), a PIN change or a host
219
+ restart end it, and its sockets are closed.
220
+
221
+ **Auth material is the one part of full scope an unlock never grants**, because each piece would
222
+ outlive the 30 minutes. An unlocked passive device cannot mint a pairing code (`POST /api/pair/mint`; a
223
+ redeemed code is a permanent full token) or a device token, revoke a device, pair or unpair a peer
224
+ (`/api/peers` writes), read the LAN identity secret, or set the password, the Tailscale gate or the
225
+ PIN. Three places enforce it: `passiveNeverAllows` in `passive.ts` (checked in `server/http.ts` right
226
+ after the allowlist), the `/api/auth/token` and `/api/auth/pin` handlers' own scope checks, and the
227
+ WebSocket `auth.set`, `auth.clear` and `auth.gateTailscale` ops (`routes/system.ts`, by the socket's
228
+ device row). A passive caller who knows the PASSWORD can still log in like any browser; the PIN model
229
+ does not cover that.
230
+
231
+ ### Browser origins
232
+
233
+ Network identity does not cover browsers: a WebSocket ignores the same-origin policy
234
+ (`packages/host/src/origin.ts`).
235
+
236
+ - Every `/api/*` request and WebSocket upgrade is checked on `Origin` and `Host` (against DNS
237
+ rebinding), with `Sec-Fetch-Site` as the backstop for `Origin`-less cross-origin GETs. A missing
238
+ `Origin` is allowed (curl, e2e, the hook sink).
239
+ - Only `http`/`https` origins naming this machine on a port it serves pass, plus one non-web origin,
240
+ `capacitor://localhost`, the iOS shell's page (WKWebView cannot serve an app from `http`). Any other
241
+ scheme is refused. The CORS header only ever reflects an origin that passed, never `*`.
242
+ - `*.ts.net` is accepted by suffix only while on a tailnet whose MagicDNS name could not be read, only
243
+ for requests arriving through `tailscale serve`, and only when the Origin names the same host the
244
+ request was sent to. The name is re-read until known. `*.local` is never a wildcard (only the
245
+ machine's own mDNS name).
246
+ - Side-effecting endpoints must not be GET (`/api/host/screenshot` is POST for this). Exactly two
247
+ read-only routes are exempt (`OPAQUE_READ_ROUTES`: artifact and inbox file, GET/HEAD only), because
248
+ the APK loads them as `<img src>` or `<a download>`.
249
+ - Path guards in `files.ts`, `git.ts` and `scaffold.ts` are asserted by e2e. `scaffold.inRoot()`
250
+ compares realpath-resolved paths (`/var` resolves to `/private/var`).
251
+
252
+ ### Local-process-only routes
253
+
254
+ `POST /api/hook` (the sink the spawned `claude` posts lifecycle events to), `POST /api/plugin/<id>/hook`
255
+ and `POST /api/shell/open` answer only a process on this machine, whatever the other settings. The
256
+ socket peer must be loopback AND the request must carry no reverse-proxy header (`X-Forwarded-*`,
257
+ `Forwarded`, `X-Real-IP`, `Via`, `Tailscale-User-*`; one list, `localProcessRefusal` in `net.ts`).
258
+ Loopback alone is not enough, because behind `tailscale serve` every tailnet request arrives from
259
+ 127.0.0.1. A loopback request whose `Host` names anything but this machine's loopback is treated as
260
+ proxied. A reverse proxy that forwards neither a proxy header nor the client `Host` is treated as
261
+ local, so front the daemon only with one that sets `X-Forwarded-For`.
262
+
263
+ `/api/agent/intents`, `/api/agent/backlog`, `/api/agent/followup` and `/api/agent/exposure[/triage]`
264
+ are for an agent in a driven repo (posting work for later, `agentintents.ts`; reading or marking a
265
+ crash or exposure finding, `agentexposure.ts`, where a mark needs a live session and never clears).
266
+ They answer only a loopback caller that is neither a browser (any `Origin` or `Sec-Fetch-Site` header
267
+ is refused, same-origin included) nor proxied (same headers as above). They add no power: a posted row
268
+ goes through the same containment gate and store as the phone's `intent.post`, cannot bring a budget
269
+ waiver, and waits for the person's auto-fire switch and budget like any scheduled item. A backlog add
270
+ never starts until the person fires it.
271
+
272
+ ## The Android app
273
+
274
+ ### Sealed sign-ins (optional, off by default)
275
+
276
+ Settings > Connection > *lock the desktop sign-ins* puts this phone's device tokens in one blob under a
277
+ Keystore key, opened on each use with the fingerprint (or the phone's PIN, on a build that supports
278
+ it), and removes the plaintext. Nothing on the host changes. **It guards against a copy of the app's
279
+ storage (a backup, a pulled file), not against a hand holding the unlocked phone** (that is the app
280
+ lock); the process holds the tokens in plaintext memory while running. A desktop with no password has
281
+ no token, so there is nothing to lock for it.
282
+
283
+ The background link depends on the APK. A current app keeps each token in memory only: alerts continue
284
+ after a swipe-away until the phone restarts or Android stops the app, then it shows "locked, open the
285
+ app". An app built before this keeps no token for the link, so alerts from gated desktops wait until
286
+ the app is opened.
287
+
288
+ **Residuals:**
289
+
290
+ - A token the desktop revoked stays in the blob until the next rewrap.
291
+ - A stray bare token that belongs to no saved desktop can stay plaintext (the row says so).
292
+ - Passive (kids') tokens are not sealed.
293
+ - On a build without PIN support the start prompt is fingerprint only, and on a plain-HTTP route it
294
+ refuses the key, so its card stays until the route is the tailnet or HTTPS. The row refuses to lock
295
+ in that state, and the host setup form refuses to save a plain address while that app has both locks
296
+ on.
297
+ - The recents thumbnail still shows the last screen.
298
+ - A lost key (screen lock removed, or a new fingerprint on a fingerprint-only key) loses the sign-ins,
299
+ so a tailnet-gated desktop must be paired again. A missing sensor never counts as a lost key. A lost
300
+ key's blob is set aside on the phone (`tokenSealDead`), not deleted; nothing restores it, and the
301
+ next lock's new key makes it unreadable.
302
+ - A key that is dead but not worded as such by an OEM stays a refusal. The way out is *forget the
303
+ locked sign-ins* (confirmed, behind the app lock), which discards the blob by hand and gives a
304
+ stranger nothing.
305
+ - "Nothing connects before the unlock" is true of the socket only. Tokenless HTTP, such as the
306
+ app-update check, runs first and carries no token.
307
+
308
+ ### App lock (optional, off by default)
309
+
310
+ Settings > Connection > *lock this app* requires the phone's fingerprint (or PIN, on a build that
311
+ supports it) before the app shows its screen. **It is a hold-the-phone defence at UI level and nothing
312
+ more.** No host route, token, gate or wire message changes, and the device token's storage is
313
+ unchanged unless *lock the desktop sign-ins* is on. The page stays mounted behind an opaque card.
314
+
315
+ **Residuals:** anyone who has the phone's PIN passes it (the PIN fallback, or by enrolling a finger).
316
+ The recents thumbnail, and the frame the WebView shows on wake before the page's JS runs, still show
317
+ the last screen.
318
+
319
+ The lock fails closed: a cancelled, failed, locked-out or unrecognised refusal never opens the app, at
320
+ any repeat count. It opens and switches itself off (saying so) only when the phone's own credential had
321
+ to change the key, the app's saved check is unreadable, or a fresh status read confirms no sensor or
322
+ nothing to check with. So on an old APK with a dead sensor that still reports hardware, or on a
323
+ plain-HTTP route (where that APK's key use is refused), the way out is a newer APK, another route (the
324
+ tailnet or HTTPS), or clearing the app's data.
325
+
326
+ ## Over the LAN
327
+
328
+ ### LAN identity and protected routes
329
+
330
+ `<state dir>/lan-identity.json` (0600) holds a `hostId` and a secret. `GET /api/lan/identity` hands them
331
+ out to loopback and Tailscale callers ONLY; every other peer kind is refused (403) before the token
332
+ gate, so a LAN token never unlocks it. `POST /api/lan/verify {nonce}` answers anyone (pre-auth,
333
+ rate-limited per remote IP) with `HMAC-SHA256(secret, hostId + "\n" + nonce)`, and the phone sends a
334
+ token over LAN only after that MAC checks out.
335
+
336
+ **Residual:** the MAC proves that whatever answers at a LAN address holds the secret. It cannot resist
337
+ an **active relay** on the person's own Wi-Fi that forwards the probe to the real desktop and then reads
338
+ the plain-HTTP traffic, token included. With the token that relay can drive the desktop, and it could
339
+ answer the app with a forged page.
340
+
341
+ So the Android app asks of every route whether it is *protected* (`isPlainRoute` in shared, mirrored
342
+ natively by `ShellRoute.java`). On a route that is not, it refuses every fingerprint use, every
343
+ over-the-air update and every app download or install, and warns in the address form, the health sheet,
344
+ the strip ("LAN, not protected") and a toast.
345
+
346
+ **The protected-route rule.** A route is protected only if it is exactly one of:
347
+
348
+ - loopback;
349
+ - a Tailscale address or MagicDNS name over `http`, or `https` to a `*.ts.net` name, unless a newer app
350
+ reports that the phone has no tailnet (then a tailnet name proves nothing: with Tailscale off, `100.x`
351
+ goes to the Wi-Fi gateway);
352
+ - the desktop's own TLS address, on an app that holds its pin;
353
+ - an `https` address the person typed themselves.
354
+
355
+ Everything else is plain, **whatever its scheme**: a relay with a public certificate for its own name
356
+ passes `https` validation. A reply from a desktop that has not proved itself (not sealed by the pairing
357
+ code, or not received over a protected route) never sets a saved desktop's address, key, pin or
358
+ identity, so a relay cannot walk a row onto itself. An app built before this rule trusts only tailnet
359
+ and HTTPS.
360
+
361
+ **Residuals:** a row saved by an older app may already hold an address someone planted (a non-`ts.net`
362
+ `https` one reads as plain and the app opens an empty field to retype it; a planted `ts.net` or `100.x`
363
+ one cannot be told apart). A person who taps "pair" on a pairing link someone else made pairs with
364
+ whoever made it (the confirm says so). A browser on plain HTTP has no such defence, because the page
365
+ itself arrives unprotected, and the token is still exposed to the relay.
366
+
367
+ ### LAN TLS and the pin
368
+
369
+ (`packages/host/src/lantls.ts`.) Only where `WATERPLANE_ALLOW_PUBLIC=1` already binds LAN addresses, the
370
+ host also listens on `WATERPLANE_TLS_PORT` (default port + 1) on those same addresses, with the same
371
+ handler and gate, so a caller there is still peer kind `lan` and needs its token. Off with
372
+ `WATERPLANE_TLS=0`; never on loopback or the tailnet; nothing without the opt-in.
373
+
374
+ The key is P-256 in `<state dir>/lan-tls.json` (0600), kept across restarts. The certificate is
375
+ self-signed and re-issued on the same key when the addresses change, so the phone's pin (SHA-256 of the
376
+ key) survives. The app stores the pin per desktop and enforces it in the WebView, the background link
377
+ and the pull service; a desktop with a pin is then never reached over plain HTTP on a LAN address. A
378
+ self-signed port does not serve the web app (it answers a notice page), because a browser click-through
379
+ would look protected and is not. With `WATERPLANE_TLS_CERT` + `WATERPLANE_TLS_KEY` (a person's own leaf
380
+ from a CA; a self-signed one is read as self-signed) it serves the app, and the pin follows that leaf.
381
+
382
+ **What the pin does not defend:**
383
+
384
+ - The WebView's handler runs only when validation fails, so a certificate valid under a CA the phone
385
+ trusts would skip the pin inside the WebView. Pinned addresses are private IPs and `.local` names,
386
+ which public CAs do not issue, and the app trusts no user-added CA.
387
+ - A first install of the APK over the network (Android accepts any signature then).
388
+ - Code already running in the page, a browser, and dropping packets.
389
+
390
+ The pin is a property of current APK builds; an older APK ignores it and stays on plain LAN with the
391
+ warnings. The way out of a pinned desktop whose TLS port is off is a confirmed "use plain HTTP for this
392
+ desktop" on its row; nothing does it by itself. The measurements behind this, taken on two emulator
393
+ WebViews only, are in `docs/notes/protocol-and-security.md`. **None of this has been tried on a real
394
+ phone.**
395
+
396
+ ### Signed updates
397
+
398
+ (`packages/host/src/updatekey.ts`, `webbundle.ts`, `packages/web/src/updateTrust.ts`,
399
+ `ShellBundleVerify.java`.) Each desktop signs its web bundle's manifest with its own P-256 key
400
+ (`<state dir>/update-key.json`, 0600), not a project key: people build their own bundles, and a private
401
+ key shipped to every desktop is no key. The manifest carries `signed` (hostId, build id, `gen`, the file
402
+ list with hashes) and `sig`.
403
+
404
+ A phone learns the desktop's public key from `welcome` only when the delivering socket's address was
405
+ protected, so a relay cannot hand it one. Once a phone holds a key, an update is refused and nothing is
406
+ written if it is unsigned, badly signed, for another desktop, or older than the last one taken (`gen`,
407
+ which rises when the served build id changes, so a real revert still works). The check runs in the page
408
+ (which works on any shipped APK) and again natively on an APK that has it. The native check also asks in
409
+ its own dialog the first time it sees a desktop's key, a changed key, or a restored desktop; page code
410
+ cannot tap it. A new key (a wiped state dir) or a lower `gen` (a restored one) waits for that answer.
411
+
412
+ **What it does not do.** A signature proves the bundle came from the desktop you paired, never that its
413
+ code is good. **A hostile desktop can still ship anything**, and a paired host stays fully trusted (see
414
+ Out of scope). An APK without the native check has the page's check only, so page code that is already
415
+ compromised can still persist a bundle there. A passive (kids') phone opens no socket, so it never
416
+ learns a key and takes updates unchecked, over a protected route or not at all. A desktop that cannot
417
+ read or write its key file serves the bundle unsigned (logged once); a phone that holds a key then
418
+ refuses it and says to update or repair the desktop.
419
+
420
+ ### Read ticket
421
+
422
+ (`auth.ts`, `POST /api/auth/read-ticket`.) An image tag or a download link cannot carry a bearer header,
423
+ so a phone with no tailnet could not load them. A signed device token buys a one-hour ticket (HMAC of
424
+ device id, scope and expiry; the key lives in memory, so a restart voids every ticket). It is accepted
425
+ as `?wt=` **only** on the two opaque read routes (`OPAQUE_READ_ROUTES`), **only** over the TLS socket,
426
+ GET/HEAD only, only while the device row exists, and held to the row's scope (a passive phone's ticket
427
+ gets what a passive token gets). Access logs, shares and copies strip `wt`. A ticket in a URL is a
428
+ credential for those two read routes for up to an hour: do not paste such a URL.
429
+
430
+ ## What a connected host does on your machine
431
+
432
+ ### Shell sessions
433
+
434
+ A shell session (a plain login shell in a repo, `backends/shell.ts`) is the same `session.create` op
435
+ behind the same gates as a `claude` session and widens nothing. Its `$BROWSER` shim posts to `POST
436
+ /api/shell/open` (local process only, above) and stores nothing for an unknown session. A URL with
437
+ userinfo in its authority (`https://github.com@evil.tld/`) is never published. Only a person's tap
438
+ opens one: a job (the intent tick, the backlog, a fan-out, an agent's post) or a peer desktop's job is
439
+ refused. The shell does not inherit the daemon's API keys and key files (`*_API_KEY`, `*_KEY_FILE`,
440
+ `*_KEYCHAIN` are unset in the pane), and its commands are never written to the session's prompt
441
+ history.
442
+
443
+ ### Live desktop
444
+
445
+ (`packages/extensions/desktop`; macOS, and Windows with the host in WSL; experimental.) Watching is a
446
+ stream on the same device-token socket and widens no gate. **Control is arbitrary input on the
447
+ desktop** (clicks and keys reach every app, password managers and a logged-in terminal included), so
448
+ it carries the same trust as the PTY and is a separate setting, **off by default**. It is gated on the
449
+ host, not only on the phone: with it off, `stream.input` is dropped before it reaches the helper; with
450
+ it on, a Mac still needs the macOS Accessibility grant. Windows asks no app for a grant, so there the
451
+ host's switch is the only gate. A peer token never reaches a stream (`/api/peer/*` only), and no job,
452
+ schedule or peer desktop opens one.
453
+
454
+ The helper `wp-desktop` runs as the desktop user, and capture runs only while a view is open. On a Mac
455
+ it is signed with a stable identifier and holds only the Screen Recording and Accessibility grants a
456
+ person gave it there (it asks macOS only when a person taps Fix on a setup row), and macOS shows its
457
+ own recording indicator throughout. Windows shows no such indicator.
458
+
459
+ ### Consent is the phone's, checked on the host
460
+
461
+ (`confirmgate.ts`.) Checklist fixes that act on the person's behalf (`CONFIRM_GATED_FIXES`: accepting
462
+ Google's Android SDK licence and downloading the toolchain, making a signing key, writing a repo's
463
+ Claude settings) and the first-app job (`POST /api/onboard`, and `/api/onboard/resume` when it adds
464
+ consent) need BOTH a browser `Origin` that passes the origin gate (an Origin-less request, allowed
465
+ elsewhere, is refused here) AND a one-shot token valid 15 minutes. The GET that draws the confirm
466
+ mints it, bound to that worktree and fix id, that path and the consent ids the screen listed, or that
467
+ job. A gated fix never runs inside a "Fix all" list. **Honest scope:** a process running as you can
468
+ run `sdkmanager` itself, so this does not stop a determined local program. It stops the spawned
469
+ `claude` agent, a plugin or a script from accepting a licence with one undocumented POST. Every
470
+ refusal is logged.
471
+
472
+ ### Ask rules
473
+
474
+ (`packages/host/src/askrules.ts`.) A desktop-wide list of permission rules (`Bash(git push * main)`,
475
+ `npm publish*`, and so on) is passed to every spawned `claude` as `permissions.ask` in the `--settings`
476
+ flag. That outranks anything the agent writes into the repo's settings and holds under
477
+ bypass-permissions; the resulting box is answerable only by a person's tap. Changing the list (`PUT
478
+ /api/askrules`) needs a browser `Origin` and a one-shot confirm minted by the GET for the list's current
479
+ revision, so a `curl` with a forged header cannot delete a rule and a stale phone cannot overwrite a
480
+ newer list. The list reaches `claude` sessions started on this desktop only: a peer desktop's jobs run
481
+ under that peer's own list, and the host's own headless calls (review, assess) take no rule. The state
482
+ file is read at boot, so a same-user edit takes effect at the next restart. **Honest scope:** it stops
483
+ the accident, not a wrapper script the rule cannot see or an agent talking you into Yes.
484
+
485
+ ### Files the host writes
486
+
487
+ - **Toolchain downloads** land in a fresh `mkdtemp` directory (0700) under the per-user data dir, are
488
+ created with `O_EXCL`, and are hashed and unpacked from there, never from a shared temp dir where
489
+ another local account could plant a file or symlink.
490
+ - **Files the host rewrites inside a repo** (`.gitignore` via the secret writer, the checklist's
491
+ gitignore fix and the signing-key link; `CLAUDE.md` and `.claude/settings.json` via checklist fixes)
492
+ are written through an in-repo symlink but never through one that leaves the worktree. A committed
493
+ `.gitignore -> ~/.gitconfig` is refused in words (`writeTextAtomicKeepMode`'s `within`), the same
494
+ realpath rule the file routes apply to reads. The repo walks (tree search, artifact discovery,
495
+ directory entry counts) neither descend into nor list the contents of a link whose target is outside
496
+ the worktree; the link itself is shown as a link, nothing more.
497
+
498
+ ### A repo's own repo-health engine
499
+
500
+ (`tools/repohealth.mjs`, run by the host after one approval, `repohealthapproval.ts`.) The engine is
501
+ pinned by a hash of the entry, its siblings and every module it imports with a plain relative path,
502
+ static or dynamic. An engine whose loads the hash cannot see is never approvable, and the phone is told
503
+ why: an `import()` or `require()` argument that is not one plain string; `eval`, `Function()`, `vm`, a
504
+ worker or loader hooks; a `#` import-map entry, or a module from a URL or an absolute path; an import
505
+ outside the repo, or a dependency committed under any `node_modules` or reached by the repo's own
506
+ package name; a tree too big to read whole. **Honest scope:** this is a comment- and string-aware text
507
+ scan, not a parser. It closes the plain shapes. An engine that spawns `node <file>` or reaches code
508
+ through an alias the scan does not know still runs what the approval never saw.
509
+
510
+ ### Plugins
511
+
512
+ (`packages/host/src/plugins/`, `docs/notes/plugins.md`.) Plugins are code the machine's owner put in
513
+ their own config dir (`~/.config/waterplane/plugins/` or `plugin-paths.json`). They run in the daemon's
514
+ process with its rights: the same trust as the daemon, crossing no boundary it has not. The phone never
515
+ runs plugin code; it draws what a plugin declares. A plugin answers a choice box only through the
516
+ core's answer gate (`answergate.ts`: the manifest's `answers`, a per-session switch that is OFF by
517
+ default and that only the phone flips, the same box it was told about, never a trust, tool or plan box
518
+ or one naming something destructive or secret). It reads files only through the containment guard, and
519
+ when loaded from outside this repo reads only the env names its manifest declares (the in-repo
520
+ extensions under `packages/extensions/` are part of the host and read its own knobs directly). Nothing
521
+ on the network can install, load or reload a plugin.
522
+
523
+ ### Peer tokens (paired desktops)
524
+
525
+ Two waterplane hosts can be paired so one delegates a job to the other (`<state dir>/peers.json`, mode
526
+ 0600; outbound rows keep the token, inbound rows only its sha256). A peer token is a scoped credential:
527
+ it unlocks `/api/peer/*` and nothing else, and a device token never unlocks those routes. Managing
528
+ pairings (`/api/peers`) is allowed on the trusted path only (loopback and Tailscale, never LAN), the
529
+ same rule as the password.
530
+
531
+ `/api/peer/*` is exempt from the device-token gate in `server/http.ts` because `peers/routes.ts`
532
+ applies a stricter check: trusted path kind, then the peer bearer token, then the peer API version
533
+ header. An unknown token gets "not paired" and the refusal is logged with the remote address. Changing
534
+ the password does NOT rotate peer tokens (a separate ledger; unpair to revoke). Peer addresses are
535
+ tailnet or loopback only (100.64/10, `*.ts.net`, 127.0.0.1), never `*.local`.
536
+
537
+ **Honest scope:** a tailnet client that sends no `Origin` can already drive a host over its WebSocket,
538
+ so pairing gives a peer no power a tailnet process lacks. It makes delegation named (the "from peer"
539
+ mark can be trusted) and refusable, not a new privilege class. A delegated job runs as an ordinary
540
+ backlog row on the peer, held by that owner's auto-fire switch and budget, in a detached worktree
541
+ (`<clone>/.claude/worktrees/peer-<from>-<key>`). It refuses to restart or install the peer's running
542
+ daemon (the marker is checked by `scripts/restart.sh` and `deploy/install-service.sh`). `GET
543
+ /api/peers` lists the peers from loopback or Tailscale only and reports this daemon's own tailnet
544
+ address, never a LAN one.
545
+
546
+ ## Installing and updating
547
+
548
+ ### The installer
549
+
550
+ `curl -fsSL https://github.com/waterplane-dev/waterplane/releases/latest/download/install.sh | sh`
551
+ (generated from `deploy/install.sh.tmpl`) is code you run as yourself, so read it first if you
552
+ prefer: download it, open it, then `sh install.sh`. It edits no shell rc file and runs at most one
553
+ `sudo` line, the Linux package manager installing `tmux`, and only after asking on the terminal.
554
+ Everything else is unprivileged.
555
+
556
+ - **Checksums.** It verifies the Node runtime against nodejs.org's `SHASUMS256.txt` and the release
557
+ archive against the `.sha256` published beside it, and installs nothing on a mismatch. Those
558
+ checksums arrive over the same HTTPS channel as the files, so on their own they catch only
559
+ corruption.
560
+ - **Signature.** The `.sha256` carries an Ed25519 signature (`.sha256.sig`) made with the project's
561
+ offline release key. The installer verifies it, using the Node it just checked against nodejs.org,
562
+ against the public key written into the script itself (`deploy/release-pubkey.pem`). A missing or
563
+ wrong signature installs nothing. That defeats a compromised release page, GitHub account or mirror
564
+ that swaps the archive and its checksum together. It does not defeat a compromised signing key,
565
+ nor an `install.sh` that was itself swapped (the key travels inside it, so read it or compare it
566
+ with the one in the repository). Rotating the key means a new `install.sh`; copies already
567
+ downloaded trust only the old key. `WATERPLANE_INSECURE_SKIP_SIG=1` skips the check, loudly, for a
568
+ release you built yourself.
569
+ - **Freshness.** The signature proves who published a release, not that it is the newest. Whoever
570
+ can write the release assets can keep offering an OLDER signed release as "latest" (a freeze),
571
+ though never one below the version already installed, which `waterplane update` refuses. A signed
572
+ expiry would close that and needs re-signing on a schedule; until then, a "current" that stays put
573
+ for long is worth a glance at the releases page.
574
+ - **`waterplane update`** holds a release to the same rule with the same key, embedded in the host
575
+ (`host/cli/releasesig.ts`, test-pinned to `deploy/release-pubkey.pem`). It verifies
576
+ `release.json.sig` before it reads `release.json` (which decides the version it fetches) and
577
+ `.sha256.sig` before it trusts the `.sha256`. A missing or wrong signature refuses the update:
578
+ nothing is installed and the current version is untouched. The same
579
+ `WATERPLANE_INSECURE_SKIP_SIG=1` skips it, with a warning on every run. The doctor's self-update
580
+ row believes a newer release only after the same check.
581
+ - **The login service** (`waterplane service install`) bakes in only PATH, HOME, its own state dir
582
+ (and the root and Claude config dir when the shell named them) and the variables passed with
583
+ `--env`, never the installing shell's environment. It refuses `WATERPLANE_ALLOW_PUBLIC` outright:
584
+ a public bind stays an explicit, visible opt-in in `.env`.
585
+
586
+ ### `node-pty` is pinned to a pre-release on purpose
587
+
588
+ The stable 1.1.0 leaks two file descriptors into the daemon on every macOS spawn (a spare
589
+ `/dev/ptmx` and the child's slave), which exhausted the machine's pseudo-terminals. The 1.2.0 betas
590
+ fix it, so the dependency is pinned exactly to `1.2.0-beta.15`. The published package's
591
+ `npm-shrinkwrap.json` holds the pin, and `ptyleak.test.mjs` guards the fix. Scanners that flag the
592
+ pre-release, or node-pty's install script (a native build fallback; prebuilt binaries ship in the
593
+ package), are seeing this choice. The pin moves to `1.2.0` once that is released stable.