openmausbot 0.1.55 → 0.1.56

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.
@@ -0,0 +1,213 @@
1
+ // What a paired device is allowed to ask for.
2
+ //
3
+ // The default is deny, and that direction is the whole point: the sidecar
4
+ // sits in front of an API it does not own and cannot see the future of. A
5
+ // route that appears in the harness later is closed to phones until someone
6
+ // decides otherwise, because the alternative is that every upstream release
7
+ // silently widens what a lost phone can reach.
8
+ //
9
+ // This file used to claim that and not do it — it listed refusals and let
10
+ // everything else under `/api/` through. In the time between writing it and
11
+ // noticing, upstream added webhook triggers, connected-app authorisation and
12
+ // routines, all of which a paired phone could drive: minting an
13
+ // internet-reachable trigger, rotating a signing secret out from under
14
+ // whatever was sending to it, disconnecting a Google account. None of that
15
+ // was a decision anyone made. It was the default.
16
+ //
17
+ // So the list below is the surface, derived from what the app actually
18
+ // calls. Adding a feature to the phone means adding its route here, on
19
+ // purpose, in a diff someone can read. That cost is the feature.
20
+ /** The one companion route that crosses into full interactive desktop
21
+ * control. Both the allowlist and capability gate consume this classifier so
22
+ * their security decisions cannot drift apart. */
23
+ export const CLOUD_DESKTOP_JOIN_ROUTE = {
24
+ method: "POST",
25
+ path: /^\/api\/bots\/[\w-]+\/computer\/join$/,
26
+ };
27
+ /** A POST whose response is file bytes. Keep this exact classifier shared
28
+ * with the proxy: a `.json` document must not enter the ordinary JSON
29
+ * scrub/re-serialise path and come back as different bytes. */
30
+ export const MESSAGE_FILE_ROUTE = {
31
+ method: "POST",
32
+ path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/file$/,
33
+ };
34
+ export const CLOUD_DESKTOP_CONTROL_ROUTE = {
35
+ method: "POST",
36
+ path: /^\/api\/bots\/[\w-]+\/computer\/(?:control|screenshot|viewer-close)$/,
37
+ };
38
+ export function isCloudDesktopJoin(method, path) {
39
+ return method === CLOUD_DESKTOP_JOIN_ROUTE.method && CLOUD_DESKTOP_JOIN_ROUTE.path.test(path);
40
+ }
41
+ export function isMessageFileDownload(method, path) {
42
+ return method === MESSAGE_FILE_ROUTE.method && MESSAGE_FILE_ROUTE.path.test(path);
43
+ }
44
+ export function isCloudDesktopAccess(method, path) {
45
+ return isCloudDesktopJoin(method, path)
46
+ || (method === CLOUD_DESKTOP_CONTROL_ROUTE.method && CLOUD_DESKTOP_CONTROL_ROUTE.path.test(path));
47
+ }
48
+ /** Every request the iOS app makes, and nothing else.
49
+ *
50
+ * Ids are `[\w-]+`, matching the harness's own route patterns. The paths
51
+ * arrive undecoded and are anchored at both ends, so an encoded traversal
52
+ * fails to match and is denied rather than forwarded — the failure mode of
53
+ * a strict pattern is a closed door, which is the one to have. */
54
+ const ALLOWED = [
55
+ // configured-or-not booleans. The write side is refused below: reading
56
+ // which providers are set up is not reading their keys.
57
+ { method: "GET", path: /^\/api\/config$/ },
58
+ { method: "GET", path: /^\/api\/events$/ },
59
+ { method: "GET", path: /^\/api\/instances$/ },
60
+ { method: "GET", path: /^\/api\/team-map$/ },
61
+ // Sidecar-owned, authenticated endpoint metadata. The proxy terminates it
62
+ // locally; it never becomes a newly exposed harness route.
63
+ { method: "GET", path: /^\/api\/companion\/endpoints$/ },
64
+ // the fleet, and making a bot
65
+ { method: "GET", path: /^\/api\/bots$/ },
66
+ { method: "POST", path: /^\/api\/bots$/ },
67
+ // One narrow, atomic organizer write. This can only file visible bots;
68
+ // unlike the desktop's broad PATCH it cannot alter execution policy.
69
+ { method: "POST", path: /^\/api\/sidebar-sections$/ },
70
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages$/ },
71
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/cards\/[\w-]+$/ },
72
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/respond$/ },
73
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/interrupt$/ },
74
+ { method: "DELETE", path: /^\/api\/bots\/[\w-]+\/queue\/[\w-]+$/ },
75
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/read$/ },
76
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/always-allow$/ },
77
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages\/[\w-]+\/edit$/ },
78
+ // A credential value crosses this one route only as an HPKE envelope. The
79
+ // server binds it to the authenticated device and exact pending card before
80
+ // Electron opens it into the OS-encrypted credential store.
81
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/secret-cards\/[\w-]+\/provide$/ },
82
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/active-branch$/ },
83
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks$/ },
84
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
85
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
86
+ { method: "DELETE", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
87
+ // Paired-safe profile subset. The harness route itself rejects fields
88
+ // outside identity, avatar, notifications, and voice preferences.
89
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/profile$/ },
90
+ // Full model selection, but no other bot settings. The harness validates
91
+ // the live catalog and refuses changes while the bot is working.
92
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/model$/ },
93
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/avatar\/generate$/ },
94
+ // Full cloud desktop access. The route is narrow and the proxy applies a
95
+ // second, per-device capability check before it reaches the harness.
96
+ CLOUD_DESKTOP_JOIN_ROUTE,
97
+ CLOUD_DESKTOP_CONTROL_ROUTE,
98
+ // rooms — making one, and talking in one
99
+ { method: "POST", path: /^\/api\/groups$/ },
100
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ },
101
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/interrupt$/ },
102
+ { method: "DELETE", path: /^\/api\/groups\/[\w-]+\/queue\/[\w-]+$/ },
103
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/read$/ },
104
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks$/ },
105
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
106
+ { method: "PATCH", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
107
+ { method: "DELETE", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
108
+ // a transcript, its images, and answering an approval
109
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages$/ },
110
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/image$/ },
111
+ MESSAGE_FILE_ROUTE,
112
+ { method: "POST", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/reactions$/ },
113
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/export$/ },
114
+ { method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ },
115
+ { method: "GET", path: /^\/api\/search$/ },
116
+ // App-owned profile images. Upload is image-only and capped at 10 MB by
117
+ // the harness; GET is a single bare generated filename, never a path.
118
+ { method: "POST", path: /^\/api\/attachments$/ },
119
+ { method: "GET", path: /^\/api\/attachments\/[\w-]+\.(?:png|jpe?g|gif|webp)$/i },
120
+ // Share-sheet documents are raw, capped at 25 MiB, and stored under a
121
+ // generated filename by the harness. The display name stays in the query;
122
+ // only this exact upload route crosses the companion boundary.
123
+ { method: "POST", path: /^\/api\/files$/ },
124
+ // Renderer-neutral voice operations. These routes never expose or mutate
125
+ // the workspace ElevenLabs key; the client receives labels or audio only.
126
+ { method: "GET", path: /^\/api\/tts\/voices$/ },
127
+ { method: "POST", path: /^\/api\/tts\/prepare$/ },
128
+ { method: "POST", path: /^\/api\/tts\/speak$/ },
129
+ // Routines create ordinary tasks using an existing agent configuration.
130
+ // Webhook management remains explicitly denied below.
131
+ { method: "GET", path: /^\/api\/routines$/ },
132
+ { method: "POST", path: /^\/api\/routines$/ },
133
+ { method: "PATCH", path: /^\/api\/routines\/[\w-]+$/ },
134
+ { method: "DELETE", path: /^\/api\/routines\/[\w-]+$/ },
135
+ { method: "POST", path: /^\/api\/routines\/[\w-]+\/run$/ },
136
+ { method: "POST", path: /^\/api\/routine-runs\/[\w-]+\/(?:cancel|seen)$/ },
137
+ // Multi-account Composio management exposes opaque ids and aliases only.
138
+ // Revocation stays on the host: the account DELETE route is deliberately
139
+ // absent — a paired client can see and add accounts, never remove one.
140
+ { method: "GET", path: /^\/api\/connectors\/catalog$/ },
141
+ { method: "GET", path: /^\/api\/connectors\/connected$/ },
142
+ { method: "GET", path: /^\/api\/connectors$/ },
143
+ { method: "POST", path: /^\/api\/connectors\/[\w-]+\/authorize$/ },
144
+ // Inline connector cards are scoped by bot, transcript message, and
145
+ // thread. They expose the same opaque OAuth authorization already allowed
146
+ // above, then only poll, resume, or dismiss that exact pending card.
147
+ { method: "GET", path: /^\/api\/bots\/[\w-]+\/connector-cards\/[\w-]+\/status$/ },
148
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/connector-cards\/[\w-]+\/(?:authorize|resume|dismiss)$/ },
149
+ // A remote client may decline or retry a credential request, but never
150
+ // claim that it stored a host credential. Saving and `provided` stay local
151
+ // to the host's OS-backed credential store.
152
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/secret-cards\/[\w-]+\/(?:resume|dismiss)$/ },
153
+ ];
154
+ /** Route families worth naming in the refusal.
155
+ *
156
+ * Everything not allowed is denied either way; this only decides whether the
157
+ * person gets a sentence or a 404. These are the ones someone might
158
+ * reasonably expect to work from the phone, where "no route" would read as a
159
+ * bug in the companion rather than a decision about where host configuration
160
+ * happens. Order matters only in that the first match wins. */
161
+ const EXPLAINED = [
162
+ {
163
+ path: /^\/api\/(companion|devices)(\/|$)/,
164
+ // Losing the phone must not mean losing the ability to lock it out.
165
+ error: "Remote access settings are managed on the host computer",
166
+ },
167
+ { path: /^\/api\/config$/, error: "API keys can only be changed on your computer" },
168
+ { path: /^\/api\/local-computer(\/|$)/, error: "the Local VM is set up on your computer" },
169
+ {
170
+ // Creating one exposes an endpoint to the internet, and rotating a
171
+ // secret breaks whatever was sending to it. Neither belongs on a device
172
+ // that lives in a pocket.
173
+ path: /^\/api\/webhooks(\/|$)/,
174
+ error: "webhooks are set up on your computer",
175
+ },
176
+ { path: /^\/api\/connectors(\/|$)/, error: "connected apps are set up on your computer" },
177
+ {
178
+ path: /^\/api\/routines(\/|$)/,
179
+ error: "this routine operation is only available on your computer",
180
+ },
181
+ { path: /^\/api\/teams(\/|$)/, error: "teams are imported and exported on your computer" },
182
+ ];
183
+ /** Why this request may not go through, or null when it may.
184
+ *
185
+ * Default deny: the answer for anything not on the list is "no route", which
186
+ * is what keeps a stolen token from mapping the API. An allowlist rather than
187
+ * a blocklist is the property this whole module exists for, and the one that
188
+ * quietly stopped being true once before. */
189
+ export function denyReason({ path, method, authenticated }) {
190
+ // Pairing is the one thing a device does before it has a credential.
191
+ if (method === "POST" && path === "/api/pair")
192
+ return null;
193
+ // Liveness is the other: it exists to be the first thing anyone curls when
194
+ // pairing will not work, and behind the token check it answered 401 to
195
+ // exactly the person it was for — which reads as "broken" rather than
196
+ // "unpaired". It discloses nothing a port scan would not.
197
+ if (method === "GET" && path === "/api/health")
198
+ return null;
199
+ if (!authenticated) {
200
+ return { status: 401, error: "pair this device from Remote access settings on the host computer" };
201
+ }
202
+ if (ALLOWED.some((route) => route.method === method && route.path.test(path)))
203
+ return null;
204
+ const explained = EXPLAINED.find((family) => family.path.test(path));
205
+ if (explained)
206
+ return { status: 403, error: explained.error };
207
+ // Everything else, including routes the harness really does have. Saying
208
+ // "no route" rather than "not allowed" keeps the sidecar from enumerating
209
+ // the API to anyone holding a stolen token — and it is what the peer-agent
210
+ // endpoints under /api/internal/ always got, since off this machine they
211
+ // genuinely do not exist.
212
+ return { status: 404, error: `no route: ${method} ${path}` };
213
+ }