@monster-orange/den 0.4.4 → 0.4.5

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 (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -25,7 +25,7 @@ export const EN_FALLBACK = {
25
25
  "ws.viewTitle":
26
26
  "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
27
27
  "ws.viewTitleFocus":
28
- "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and focus ({focus}) stays the target for commands.",
28
+ "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and the CLI default ({focus}) stays the target for commands.",
29
29
  };
30
30
  function enT(key, vars) {
31
31
  return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
@@ -26,10 +26,18 @@
26
26
  * included (Ward, 2026-11-06: the lock, R8, is the only prompt there;
27
27
  * Host, Origin and CSRF cover CSRF and DNS rebinding). SIGNIN_FREE
28
28
  * routes never need one.
29
- * - The edge counts as local (0.4.3, edgeClientIsLocal): the edge hostname
30
- * from an edge peer gets loopback's treatment unless the edge's
31
- * X-Forwarded-For names another machine, or this server is deliberately
32
- * exposed (`exposed`: a non-loopback DEN_BIND_HOST bind).
29
+ * - The edge (0.4.3, 0.4.5: edgeClient): the edge hostname from an edge
30
+ * peer gets loopback's treatment when the edge attests the client is
31
+ * this machine (the right-most X-Forwarded-For entry, the one the edge
32
+ * itself appended, is loopback or one of this machine's addresses). A
33
+ * client it can't place (none, or a Docker/VM gateway's address) signs
34
+ * in unless `web.edge_unattested: local`; another machine always does,
35
+ * as does every edge request when this server is deliberately exposed
36
+ * (`exposed`: a non-loopback DEN_BIND_HOST bind). `localhost` (a
37
+ * loopback Host from a loopback peer) never asks.
38
+ * - Signing in with GitHub (SEC-2a): its start and poll are sign-in free,
39
+ * and its start needs an allowed Origin but no CSRF token (a signed-out
40
+ * page has none; route-classes.ts SIGNIN_CSRF_FREE).
33
41
  * - Origin, on POST/PUT/PATCH/DELETE and every WebSocket upgrade: an
34
42
  * allowed host (http or https). No Origin needs a valid bearer.
35
43
  * Otherwise 403 `origin_rejected`.
@@ -40,7 +48,8 @@
40
48
  * GETs, pages and SSE streams on loopback pass with only the Host check
41
49
  * (§6), except the GETs route-classes.ts marks `mutating` or `shell-grade`
42
50
  * (a run, a service's lifecycle command): same-origin only, or a CLI's
43
- * bearer.
51
+ * bearer. A sign-in GET (SIGNIN_GETS: /auth, the GitHub sign-in poll) may
52
+ * also come with no fetch metadata at all, as a plain-http LAN origin's do.
44
53
  *
45
54
  * The audit actor (auditActorOf, principalOf) comes from the credential,
46
55
  * never from a header the caller sets: `agent:<id>` for an actor token,
@@ -61,7 +70,9 @@ import {
61
70
  isMutatingGet,
62
71
  pathMatches,
63
72
  routeClass,
73
+ SIGNIN_CSRF_FREE,
64
74
  SIGNIN_FREE,
75
+ SIGNIN_GETS,
65
76
  type RouteClass,
66
77
  } from "./route-classes.js";
67
78
 
@@ -143,6 +154,11 @@ export interface GuardContext {
143
154
  * needs a sign-in too, as before 0.4.3.
144
155
  */
145
156
  exposed?: boolean;
157
+ /**
158
+ * `web.edge_unattested` (SEC-2a): an edge client Den can't place signs
159
+ * in (`signin`, the default when unset) or counts as local (`local`).
160
+ */
161
+ edgeUnattested?: "local" | "signin";
146
162
  }
147
163
 
148
164
  export type GuardResult =
@@ -155,6 +171,8 @@ export type GuardResult =
155
171
  beyond: boolean;
156
172
  /** The signed-in session, when the cookie names a live one. */
157
173
  session: GuardSession | null;
174
+ /** The client's address when Den can attest it (SEC-2a: a device's record). */
175
+ client?: string;
158
176
  }
159
177
  | {
160
178
  ok: false;
@@ -329,14 +347,10 @@ function inSubnet(a: string, net4: { address: string; netmask: string }) {
329
347
  return m > 0 && lo(ipv4(a)) === lo(ipv4(net4.address));
330
348
  }
331
349
 
332
- /**
333
- * The original client X-Forwarded-For names: its left-most entry, as an
334
- * IP (a port or brackets dropped), or undefined when there is none.
335
- */
336
- export function forwardedClient(xff: string | undefined): string | undefined {
337
- const first = xff?.split(",")[0]?.trim();
338
- if (!first) return undefined;
339
- let a = first;
350
+ /** One X-Forwarded-For entry as an IP (a port or brackets dropped), or undefined. */
351
+ function forwardedEntry(entry: string | undefined): string | undefined {
352
+ let a = entry?.trim();
353
+ if (!a) return undefined;
340
354
  const b = /^\[([^\]]+)\](?::\d+)?$/.exec(a);
341
355
  if (b) a = b[1];
342
356
  else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(a)) a = a.replace(/:\d+$/, "");
@@ -345,31 +359,48 @@ export function forwardedClient(xff: string | undefined): string | undefined {
345
359
  }
346
360
 
347
361
  /**
348
- * The edge counts as local (0.4.3): whether an edge request (the edge
349
- * hostname from an edge peer) comes from this machine, by the left-most
350
- * X-Forwarded-For entry (the original client):
362
+ * The client the edge vouches for (SEC-2a): the right-most X-Forwarded-For
363
+ * entry, the one the edge's proxy appended itself (its TCP peer, or the
364
+ * client a trusted front passed on with the PROXY protocol). The entries
365
+ * left of it are whatever the client, or a hop the edge trusts, sent, so
366
+ * they are never believed (0.4.4 and earlier read the left-most: forgeable). Undefined
367
+ * when there is none, or it isn't an IP.
368
+ */
369
+ export function forwardedClient(xff: string | undefined): string | undefined {
370
+ const all = xff?.split(",") ?? [];
371
+ return forwardedEntry(all[all.length - 1]);
372
+ }
373
+
374
+ /** Who an edge request's client is, as far as Den can tell (edgeClient). */
375
+ export type EdgeClient = "local" | "unattested" | "foreign";
376
+
377
+ /**
378
+ * Who an edge request (the edge hostname from an edge peer) comes from, by
379
+ * the client the edge vouches for (forwardedClient):
351
380
  * - loopback, or one of this machine's own interface addresses (its LAN
352
- * or VPN address, a Tailscale 100.x one included): local;
381
+ * or VPN address, a Tailscale 100.x one included): `local`;
353
382
  * - missing, unreadable, or a container/VM network's address (the Docker
354
383
  * ranges isEdgePeer takes, a subnet of one of this machine's bridges,
355
384
  * one of the edge's own Docker networks, or OrbStack's 0.250.250.254):
356
- * we can't tell (Docker Desktop and OrbStack on macOS hand the
357
- * edge their gateway's address, and a TCP passthrough in front hides the
358
- * client), so local;
359
- * - anything else: another machine, beyond loopback (sign-in required).
360
- * The edge's own page warns when it is reachable beyond this machine
361
- * (edge-exposure.ts): with no sign-in, anyone who reaches it is local.
385
+ * `unattested`. Docker Desktop and OrbStack on macOS hand the edge
386
+ * their gateway's address, and a TCP passthrough in front hides the
387
+ * client, so Den can't tell. With SEC-2a it signs in, unless
388
+ * `web.edge_unattested: local` (0.4.4's allow);
389
+ * - anything else: `foreign`, another machine (sign-in required).
362
390
  */
363
- export function edgeClientIsLocal(
391
+ export function edgeClient(
364
392
  xff: string | undefined,
365
393
  local: LocalNet = { own: [], bridges: [] },
366
- ): boolean {
394
+ ): EdgeClient {
367
395
  const c = forwardedClient(xff);
368
- if (!c || isEdgePeer(c) || local.own.includes(c)) return true;
396
+ if (!c) return "unattested";
397
+ if (isLoopbackAddress(c)) return "local";
398
+ if (isEdgePeer(c) || local.bridges.some((b) => inSubnet(c, b)))
399
+ return "unattested";
369
400
  // 0.0.0.0/8 ("this network") is never a client on another machine:
370
401
  // OrbStack presents the host to its containers as 0.250.250.254.
371
- if (net.isIPv4(c) && c.startsWith("0.")) return true;
372
- return local.bridges.some((b) => inSubnet(c, b));
402
+ if (net.isIPv4(c) && c.startsWith("0.")) return "unattested";
403
+ return local.own.includes(c) ? "local" : "foreign";
373
404
  }
374
405
 
375
406
  /**
@@ -479,13 +510,25 @@ export function checkRequest(
479
510
  "127.0.0.1, [::1] with its port, or its edge hostname).",
480
511
  };
481
512
  const peerLoopback = ctx.peerIsLoopback ? ctx.peerIsLoopback(req) : true;
482
- // The edge from this machine (edgeClientIsLocal) is local, unless this
483
- // server is deliberately exposed; a LAN name or peer is beyond.
513
+ // The edge: local when it attests this machine; a client it can't place
514
+ // signs in unless web.edge_unattested is local; never local when this
515
+ // server is deliberately exposed. A LAN name or peer is beyond.
516
+ const xff = header(req, "x-forwarded-for");
517
+ const who = via === "edge" ? edgeClient(xff, ctx.localNet?.()) : null;
484
518
  const beyond =
485
519
  via === "edge"
486
520
  ? !!ctx.exposed ||
487
- !edgeClientIsLocal(header(req, "x-forwarded-for"), ctx.localNet?.())
521
+ who === "foreign" ||
522
+ (who === "unattested" && ctx.edgeUnattested !== "local")
488
523
  : via !== "loopback" || !peerLoopback;
524
+ // The address a device's record keeps: the edge's attested client, or
525
+ // the peer itself off the edge.
526
+ const client =
527
+ via === "edge"
528
+ ? who === "unattested"
529
+ ? undefined
530
+ : forwardedClient(xff)
531
+ : (req.socket?.remoteAddress ?? undefined);
489
532
 
490
533
  let principal: Principal | null = null;
491
534
  const auth = header(req, "authorization");
@@ -532,7 +575,8 @@ export function checkRequest(
532
575
  }
533
576
 
534
577
  // §4: a session (or a bearer) beyond loopback; on loopback, never.
535
- if (!principal && !session && !SIGNIN_FREE.includes(pathname) && beyond) {
578
+ const signinFree = SIGNIN_FREE.some((r) => pathMatches(r, pathname));
579
+ if (!principal && !session && !signinFree && beyond) {
536
580
  const scope: SigninScope = "den";
537
581
  return {
538
582
  ok: false,
@@ -550,6 +594,7 @@ export function checkRequest(
550
594
  via,
551
595
  beyond,
552
596
  session,
597
+ ...(client ? { client: normAddress(client) } : {}),
553
598
  });
554
599
  const browser: Principal | null = session
555
600
  ? { kind: "person-browser", session: session.key }
@@ -557,7 +602,7 @@ export function checkRequest(
557
602
 
558
603
  if (!MUTATING.has(method) && !isUpgrade(req)) {
559
604
  if (isMutatingGet(method, pathname)) {
560
- const refused = mutatingGetRefusal(req, ctx, principal, cli);
605
+ const refused = mutatingGetRefusal(req, ctx, pathname, principal, cli);
561
606
  if (refused) return refused;
562
607
  }
563
608
  return ok(principal ?? browser);
@@ -584,6 +629,10 @@ export function checkRequest(
584
629
  };
585
630
  // A valid bearer is a CLI's or an agent's call: no CSRF to prove.
586
631
  if (principal) return ok(principal);
632
+ // Starting a GitHub sign-in from the signed-out page (which holds no
633
+ // CSRF token): the allowed Origin above is the check.
634
+ if (!session && SIGNIN_CSRF_FREE.some((r) => pathMatches(r, pathname)))
635
+ return ok(null);
587
636
  // A browser's WebSocket can't send a header: on an upgrade the token
588
637
  // rides as a subprotocol, `den.csrf.<token>` (AG-4a-term's io).
589
638
  const csrf =
@@ -606,6 +655,8 @@ export function checkRequest(
606
655
  export const AGENT_FORBIDDEN: readonly string[] = [
607
656
  // web.secret's management and the browser sign-in: an agent mints nothing.
608
657
  "/api/web/*",
658
+ "/api/github/*",
659
+ "/api/signin/*",
609
660
  "/auth",
610
661
  "/signin",
611
662
  "/api/session/logout",
@@ -673,13 +724,15 @@ function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
673
724
  * is a GET too, so these pass only:
674
725
  * - from this server's own pages: Sec-Fetch-Site same-origin (or none, a
675
726
  * URL the person typed), and an Origin, if any, that is allowed;
676
- * - or with neither header (the CLI, curl, MCP) and a valid bearer.
727
+ * - or with neither header (the CLI, curl, MCP) and a valid bearer, or
728
+ * (SIGNIN_GETS) a sign-in GET, which carries its own credential.
677
729
  * Sec-Fetch-Site same-site or cross-site: 403 `cross_site` (another port on
678
730
  * localhost is same-site, not same-origin).
679
731
  */
680
732
  function mutatingGetRefusal(
681
733
  req: http.IncomingMessage,
682
734
  ctx: GuardContext,
735
+ pathname: string,
683
736
  principal: Principal | null,
684
737
  cli: string,
685
738
  ): Extract<GuardResult, { ok: false }> | null {
@@ -699,7 +752,21 @@ function mutatingGetRefusal(
699
752
  code: "origin_rejected",
700
753
  error: "Requests from another site can't start or change anything here.",
701
754
  };
702
- if (site === undefined && origin === undefined && !principal)
755
+ // A sign-in GET (route-classes.ts SIGNIN_GETS) may come with neither
756
+ // header. A plain-http origin that isn't localhost (a LAN bind with
757
+ // web.allow_plain_http_remote) is not "potentially trustworthy", so
758
+ // Chromium sends it no Sec-Fetch-* headers, and a top-level navigation
759
+ // sends no Origin: the person's own `web login --origin` link arrives
760
+ // bare. That is safe: the request carries its own unguessable credential
761
+ // (/auth a one-time code derived from web.secret, the poll its flow id),
762
+ // which another site can't know, and a cross-site request from a modern
763
+ // browser sends Sec-Fetch-Site or Origin, which the checks above refuse.
764
+ if (
765
+ site === undefined &&
766
+ origin === undefined &&
767
+ !principal &&
768
+ !SIGNIN_GETS.some((r) => pathMatches(r, pathname))
769
+ )
703
770
  return {
704
771
  ok: false,
705
772
  status: 403,
@@ -183,9 +183,9 @@ export const ROUTES: readonly RouteEntry[] = [
183
183
 
184
184
  // ── Sign-in (SEC-1b, §2) ────────────────────────────────────────────────
185
185
  M(
186
- ["GET", "HEAD"],
186
+ ["GET", "HEAD", "POST"],
187
187
  "/auth",
188
- "swaps a one-time login code for a session cookie",
188
+ "swaps a one-time login code for a session cookie (POST: with the code `web login` printed)",
189
189
  ),
190
190
  R(
191
191
  "/signin",
@@ -202,6 +202,71 @@ export const ROUTES: readonly RouteEntry[] = [
202
202
  "/api/web/logout",
203
203
  "ends browser sessions, and rotates web.secret (`web logout`)",
204
204
  ),
205
+ // ── SEC-2a: GitHub sign-in, the owner, pairing ─────────────────────────
206
+ M(
207
+ ["POST"],
208
+ "/api/github/signin",
209
+ "starts a GitHub sign-in (a device flow the server runs)",
210
+ ),
211
+ M(
212
+ ["GET", "HEAD"],
213
+ "/api/github/signin/:id",
214
+ "a GitHub sign-in's outcome; once the owner approved it, sets the session cookie",
215
+ ),
216
+ R("/api/web/owner", "who the owner is (`web owner show`)"),
217
+ S(
218
+ ["POST"],
219
+ "/api/web/owner/link",
220
+ "links the owner's GitHub account (`web owner link`)",
221
+ ),
222
+ S(
223
+ ["GET", "HEAD"],
224
+ "/api/web/owner/link/:id",
225
+ "an owner link's device code and outcome",
226
+ ),
227
+ S(
228
+ ["POST"],
229
+ "/api/web/owner/unlink",
230
+ "unlinks the owner (`web owner unlink`)",
231
+ ),
232
+ R("/api/web/pair", "the edge hostnames a pairing link can be for"),
233
+ // ── SEC-2a: approve this computer with a code (pair-routes.ts) ─────────
234
+ M(
235
+ ["POST"],
236
+ "/api/signin/code",
237
+ "asks for a code the owner approves this browser with (in memory, 10 minutes)",
238
+ ),
239
+ M(
240
+ ["GET", "HEAD"],
241
+ "/api/signin/code/:id",
242
+ "a code request's state; once approved, sets the session cookie (once)",
243
+ ),
244
+ S(
245
+ ["GET", "HEAD"],
246
+ "/api/web/waiting",
247
+ "lists the devices waiting for approval (Settings › Access, `web approve --list`)",
248
+ ),
249
+ S(
250
+ ["POST"],
251
+ "/api/web/approve",
252
+ "approves a waiting device: it signs in (`web approve <code>`)",
253
+ ),
254
+ S(["POST"], "/api/web/deny", "denies a waiting device"),
255
+ S(
256
+ ["POST"],
257
+ "/api/web/logins",
258
+ "registers `web login`'s link and its code, to wait on",
259
+ ),
260
+ S(
261
+ ["GET", "HEAD"],
262
+ "/api/web/logins/:id",
263
+ "`web login`'s wait: whether its link was used, by which browser",
264
+ ),
265
+ S(
266
+ ["POST"],
267
+ "/api/web/pair",
268
+ "mints a one-time sign-in link for another device (Pair a device)",
269
+ ),
205
270
 
206
271
  // ── Mutations (checked by method) ───────────────────────────────────────
207
272
  M(["POST"], "/api/activity", "an agent's refused-call report"),
@@ -274,6 +339,37 @@ export const SIGNIN_FREE: readonly string[] = [
274
339
  "/auth",
275
340
  "/signin",
276
341
  "/api/session",
342
+ // SEC-2a: Sign in with GitHub, from the signed-out page.
343
+ "/api/github/signin",
344
+ "/api/github/signin/:id",
345
+ // SEC-2a: approve this computer with a code, from the signed-out page.
346
+ "/api/signin/code",
347
+ "/api/signin/code/:id",
348
+ ];
349
+
350
+ /**
351
+ * SIGNIN_FREE mutations a signed-out page makes, which holds no CSRF token:
352
+ * they need an allowed Origin only (SEC-2a: starting a GitHub sign-in, and
353
+ * asking for a code to be approved with).
354
+ */
355
+ export const SIGNIN_CSRF_FREE: readonly string[] = [
356
+ "/api/github/signin",
357
+ "/api/signin/code",
358
+ // SEC-2a: a waited-on link's page posts the code `web login` printed.
359
+ "/auth",
360
+ ];
361
+
362
+ /**
363
+ * Sign-in GETs that carry their own unguessable credential: /auth a one-time
364
+ * code derived from web.secret, the GitHub sign-in poll its flow id, the
365
+ * code request's poll its own id (never the code). They
366
+ * may come with no fetch metadata at all (request-guard.ts,
367
+ * mutatingGetRefusal): a plain-http LAN origin gets none from the browser.
368
+ */
369
+ export const SIGNIN_GETS: readonly string[] = [
370
+ "/auth",
371
+ "/api/github/signin/:id",
372
+ "/api/signin/code/:id",
277
373
  ];
278
374
 
279
375
  /** The dispatchers' route regexes, by `<file>:<const>`, and their entries. */
@@ -285,6 +381,10 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
285
381
  ],
286
382
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
287
383
  "session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
384
+ "github-routes.ts:SIGNIN_PATH": ["/api/github/signin/:id"],
385
+ "github-routes.ts:LINK_PATH": ["/api/web/owner/link/:id"],
386
+ "pair-routes.ts:POLL_PATH": ["/api/signin/code/:id"],
387
+ "index.ts:LOGIN_WAIT_PATH": ["/api/web/logins/:id"],
288
388
  };
289
389
 
290
390
  /** Whether `pathname` matches a table path. */
@@ -36,6 +36,8 @@ export const UI_MODULES: readonly string[] = [
36
36
  "/agents-page.js",
37
37
  // The Start agent form's choices, shared by both (AG-10.0).
38
38
  "/agent-start.js",
39
+ // SEC-2a: Pair a device's QR code (a tiny encoder of Den's own).
40
+ "/qr.js",
39
41
  // The UI's catalogs other than English (R19; en is in shell.js).
40
42
  "/i18n/ja.js",
41
43
  "/i18n/es.js",