@monster-orange/den 0.4.0 → 0.4.2

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 (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -0,0 +1,670 @@
1
+ /**
2
+ * SEC-1a and SEC-1b (SEC-1 spec §3, §4): the checks every request passes
3
+ * before any route.
4
+ *
5
+ * - Host allowlist (DNS rebinding): `localhost`, `127.0.0.1` and `[::1]`
6
+ * with the server's port, plus the edge hostnames that route to Den, plus
7
+ * (a non-loopback bind with `web.allow_plain_http_remote`) this
8
+ * machine's own LAN names with the port. Anything else: 421
9
+ * `misdirected`.
10
+ * - Credential: `Authorization: Bearer den_pt_…` (the person token, §5)
11
+ * or `den_at_…` (SEC-1c: an agent's actor token, live only while its
12
+ * session runs). A bearer that doesn't verify: 401 `unauthorized`; an
13
+ * actor token `web logout --all` revoked: 401 `token_revoked` ("restart
14
+ * or resume the agent"). Or
15
+ * the browser's session cookie (SEC-1b), looked up server-side.
16
+ * - An agent's token (SEC-1c, §5) never reaches: web.secret's management
17
+ * and sign-in (/api/web/*, /auth, /api/session/logout), the lock (R8:
18
+ * /api/sudo-password*), or a shell-grade route other than its own
19
+ * session's /api/sessions/<id>/*: agent start and resume mint tokens.
20
+ * 403 `agent_forbidden`. WS-3a (0.4.2) adds the workspace check at
21
+ * agentRefusal's seam.
22
+ * - Sign-in (§4, route-classes.ts): beyond loopback (the edge hostname, a
23
+ * LAN name, or a peer that isn't loopback) every route needs a session
24
+ * or a bearer. Otherwise 401 `signin_required`. On loopback no route
25
+ * does, shell-grade ones included (Ward, 2026-11-06: the lock, R8, is
26
+ * the only prompt there; Host, Origin and CSRF cover CSRF and DNS
27
+ * rebinding). SIGNIN_FREE routes never need one.
28
+ * - Origin, on POST/PUT/PATCH/DELETE and every WebSocket upgrade: an
29
+ * allowed host (http or https). No Origin needs a valid bearer.
30
+ * Otherwise 403 `origin_rejected`.
31
+ * - CSRF: a browser mutation (Origin, no bearer) carries `X-Den-CSRF`:
32
+ * the session's token when it is signed in, else (loopback) this server
33
+ * start's. Both come from `GET /api/session`. Otherwise 403 `csrf`.
34
+ *
35
+ * GETs, pages and SSE streams on loopback pass with only the Host check
36
+ * (§6), except the GETs route-classes.ts marks `mutating` or `shell-grade`
37
+ * (a run, a service's lifecycle command): same-origin only, or a CLI's
38
+ * bearer.
39
+ *
40
+ * The audit actor (auditActorOf, principalOf) comes from the credential,
41
+ * never from a header the caller sets: `agent:<id>` for an actor token,
42
+ * `person:<os user>` for a person token or a signed-in browser. An
43
+ * x-den-actor claim is kept only as an unverified label.
44
+ */
45
+ import { randomBytes, timingSafeEqual, createHash } from "crypto";
46
+ import type * as http from "http";
47
+ import * as net from "net";
48
+ import {
49
+ actorFromHeaders,
50
+ type Actor,
51
+ type AgentActor,
52
+ } from "../../shared/agent-actor.js";
53
+ import { osUser } from "../../shared/web-secret.js";
54
+ import {
55
+ isMutatingGet,
56
+ pathMatches,
57
+ routeClass,
58
+ SIGNIN_FREE,
59
+ type RouteClass,
60
+ } from "./route-classes.js";
61
+
62
+ export const CSRF_HEADER = "x-den-csrf";
63
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
64
+
65
+ /** An agent's actor token (SEC-1c): what it was minted as. */
66
+ export interface AgentPrincipal {
67
+ kind: "agent";
68
+ actor: `agent:${string}`;
69
+ workspace: string;
70
+ grants: readonly string[];
71
+ /** The session it runs in: its name and the host's id for it. */
72
+ session: { name: string; id?: string };
73
+ }
74
+
75
+ /** Who a request proved itself to be. */
76
+ export type Principal =
77
+ | { kind: "person"; actor: `person:${string}` }
78
+ | AgentPrincipal
79
+ | { kind: "person-browser"; session?: string };
80
+
81
+ /** What the request checks need of a signed-in browser session. */
82
+ export interface GuardSession {
83
+ /** The record's key (never the cookie's value). */
84
+ key: string;
85
+ /** The session's own CSRF token. */
86
+ csrf: string;
87
+ }
88
+
89
+ /** How a request reached Den. `edge` and `lan` are beyond loopback. */
90
+ export type Via = "loopback" | "edge" | "lan";
91
+
92
+ export interface GuardContext {
93
+ /** The server's own port. */
94
+ port: number;
95
+ /** Loopback names accepted with the port (lowercase, IPv6 bracketed). */
96
+ loopbackNames: readonly string[];
97
+ /** Edge hostnames that route to Den (lowercase), any port. */
98
+ edgeHosts: () => readonly string[];
99
+ /**
100
+ * This machine's LAN names, accepted with the port (a non-loopback bind
101
+ * with `web.allow_plain_http_remote` only). Beyond loopback.
102
+ */
103
+ lanHosts?: () => readonly string[];
104
+ /**
105
+ * What a bearer token proves (a person token's `person:<user>`, an actor
106
+ * token's agent), `"revoked"` for an actor token `web logout --all`
107
+ * revoked (SEC-1 §6c.2), or null when it doesn't verify.
108
+ */
109
+ verifyBearer: (
110
+ token: string,
111
+ ) => `person:${string}` | AgentPrincipal | "revoked" | null;
112
+ /** This server start's CSRF token (loopback, no session). */
113
+ csrfToken: string;
114
+ /** The live session the request's cookie names (SEC-1b), or null. */
115
+ session?: (req: http.IncomingMessage) => GuardSession | null;
116
+ /**
117
+ * Whether the request's peer is loopback. Unset: taken as loopback (a
118
+ * request object without a socket, in a unit test); the server sets it.
119
+ */
120
+ peerIsLoopback?: (req: http.IncomingMessage) => boolean;
121
+ /**
122
+ * Whether the request's peer is one the edge's proxy runs on (isEdgePeer:
123
+ * loopback or a Docker network). The edge hostname is believed only from
124
+ * one: a LAN client that sends `Host: <edge hostname>` straight to this
125
+ * server's port gets 421. Unset: taken as yes (a unit test).
126
+ */
127
+ peerIsEdge?: (req: http.IncomingMessage) => boolean;
128
+ }
129
+
130
+ export type GuardResult =
131
+ | {
132
+ ok: true;
133
+ principal: Principal | null;
134
+ /** How the request reached Den. */
135
+ via: Via;
136
+ /** Beyond loopback: the edge, a LAN name, or a non-loopback peer. */
137
+ beyond: boolean;
138
+ /** The signed-in session, when the cookie names a live one. */
139
+ session: GuardSession | null;
140
+ }
141
+ | {
142
+ ok: false;
143
+ status: 401 | 403 | 421;
144
+ code:
145
+ | "misdirected"
146
+ | "unauthorized"
147
+ | "token_revoked"
148
+ | "signin_required"
149
+ | "origin_rejected"
150
+ | "csrf"
151
+ | "cross_site"
152
+ | "agent_forbidden";
153
+ error: string;
154
+ /** signin_required: why (beyond loopback: `den`). */
155
+ scope?: SigninScope;
156
+ via?: Via;
157
+ beyond?: boolean;
158
+ };
159
+
160
+ /** A fresh per-start CSRF token. */
161
+ export function newCsrfToken(): string {
162
+ return randomBytes(32).toString("base64url");
163
+ }
164
+
165
+ /** `host[:port]` → { name, port }; IPv6 names keep their brackets. */
166
+ export function parseHostHeader(
167
+ raw: string,
168
+ ): { name: string; port: number | null } | null {
169
+ const v = raw.trim().toLowerCase();
170
+ if (!v) return null;
171
+ const m = /^(\[[0-9a-f:.]+\]|[^:[\]]+)(?::(\d{1,5}))?$/.exec(v);
172
+ if (!m) return null;
173
+ return {
174
+ name: m[1].replace(/\.$/, ""),
175
+ port: m[2] === undefined ? null : Number(m[2]),
176
+ };
177
+ }
178
+
179
+ /** Where an allowed Host reaches Den from, or null when it isn't allowed. */
180
+ export function hostAllowed(
181
+ raw: string | undefined,
182
+ ctx: Pick<GuardContext, "port" | "loopbackNames" | "edgeHosts" | "lanHosts">,
183
+ ): Via | null {
184
+ if (!raw) return null;
185
+ const h = parseHostHeader(raw);
186
+ if (!h) return null;
187
+ if (ctx.loopbackNames.includes(h.name) && h.port === ctx.port)
188
+ return "loopback";
189
+ if (ctx.edgeHosts().includes(h.name)) return "edge";
190
+ if (h.port === ctx.port && (ctx.lanHosts?.() ?? []).includes(h.name))
191
+ return "lan";
192
+ return null;
193
+ }
194
+
195
+ /** The Origin header's host, when it is http(s) and an allowed host. */
196
+ export function originAllowed(
197
+ raw: string | undefined,
198
+ ctx: Pick<GuardContext, "port" | "loopbackNames" | "edgeHosts" | "lanHosts">,
199
+ ): boolean {
200
+ if (!raw) return false;
201
+ let u: URL;
202
+ try {
203
+ u = new URL(raw);
204
+ } catch {
205
+ return false;
206
+ }
207
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
208
+ if (u.username || u.password || (u.pathname && u.pathname !== "/"))
209
+ return false;
210
+ const port = u.port || (u.protocol === "https:" ? "443" : "80");
211
+ return hostAllowed(`${u.hostname}:${port}`, ctx) !== null;
212
+ }
213
+
214
+ function same(a: string, b: string): boolean {
215
+ const x = createHash("sha256").update(a).digest();
216
+ const y = createHash("sha256").update(b).digest();
217
+ return timingSafeEqual(x, y);
218
+ }
219
+
220
+ function header(req: http.IncomingMessage, name: string): string | undefined {
221
+ const v = req.headers[name];
222
+ return Array.isArray(v) ? v[0] : v;
223
+ }
224
+
225
+ /** An upgrade's CSRF token, offered as the subprotocol `den.csrf.<token>`. */
226
+ export const CSRF_PROTOCOL_PREFIX = "den.csrf.";
227
+ function csrfFromProtocols(req: http.IncomingMessage): string | undefined {
228
+ const raw = req.headers["sec-websocket-protocol"];
229
+ const all = (Array.isArray(raw) ? raw.join(",") : (raw ?? ""))
230
+ .split(",")
231
+ .map((s) => s.trim());
232
+ const p = all.find((s) => s.startsWith(CSRF_PROTOCOL_PREFIX));
233
+ return p ? p.slice(CSRF_PROTOCOL_PREFIX.length) : undefined;
234
+ }
235
+
236
+ /** A WebSocket (or any) upgrade request. */
237
+ export function isUpgrade(req: http.IncomingMessage): boolean {
238
+ return header(req, "upgrade") !== undefined;
239
+ }
240
+
241
+ /** A loopback IP (127/8, ::1, or either IPv4-mapped). */
242
+ export function isLoopbackAddress(addr: string | undefined): boolean {
243
+ if (!addr) return false;
244
+ const a = addr.toLowerCase().replace(/^::ffff:/, "");
245
+ if (a === "::1") return true;
246
+ return net.isIPv4(a) && a.startsWith("127.");
247
+ }
248
+
249
+ /**
250
+ * A peer the edge's X-Forwarded-Proto is believed from: loopback, or a
251
+ * Docker network (172.16.0.0/12, Docker Desktop's 192.168.65.0/24), where
252
+ * the edge's Traefik runs.
253
+ */
254
+ export function isEdgePeer(addr: string | undefined): boolean {
255
+ if (isLoopbackAddress(addr)) return true;
256
+ const a = String(addr ?? "")
257
+ .toLowerCase()
258
+ .replace(/^::ffff:/, "");
259
+ if (!net.isIPv4(a)) return false;
260
+ const [x, y, z] = a.split(".").map(Number);
261
+ return (
262
+ (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168 && z === 65)
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Whether the request came over HTTPS: the socket's own encryption, or,
268
+ * only for the edge hostname from an edge peer, `X-Forwarded-Proto: https`.
269
+ * A spoofed header on a loopback Host never counts.
270
+ */
271
+ export function requestIsHttps(
272
+ req: http.IncomingMessage,
273
+ via: Via | null,
274
+ ): boolean {
275
+ if ((req.socket as { encrypted?: boolean } | undefined)?.encrypted)
276
+ return true;
277
+ if (via !== "edge" || !isEdgePeer(req.socket?.remoteAddress)) return false;
278
+ const xfp = header(req, "x-forwarded-proto");
279
+ return xfp?.split(",")[0].trim().toLowerCase() === "https";
280
+ }
281
+
282
+ /** A bind address that only this machine can reach. */
283
+ export function isLoopbackBind(host: string): boolean {
284
+ const h = host
285
+ .trim()
286
+ .toLowerCase()
287
+ .replace(/^\[|\]$/g, "");
288
+ return h === "localhost" || isLoopbackAddress(h);
289
+ }
290
+
291
+ /**
292
+ * A Docker bridge address (172.16.0.0/12): where a Linux host's containers
293
+ * reach it as host.docker.internal (`host-gateway`, the edge's
294
+ * extra_hosts). One address, never 0.0.0.0 or ::.
295
+ */
296
+ export function isDockerBridgeAddress(addr: string): boolean {
297
+ const a = addr.trim();
298
+ if (!net.isIPv4(a)) return false;
299
+ const [x, y] = a.split(".").map(Number);
300
+ return x === 172 && y >= 16 && y <= 31;
301
+ }
302
+
303
+ /**
304
+ * §4: why a bind may not start, or null. A non-loopback bind needs
305
+ * `web.allow_plain_http_remote: true`, edge or no edge: whatever is in
306
+ * front, this server's own port still answers plain HTTP on that address,
307
+ * so a session cookie (or a LAN client posing as the edge) would cross the
308
+ * network in clear text. The one exception is the edge's own path on
309
+ * Linux: with HTTPS in front (`fronted`), a Docker bridge address (the
310
+ * host-gateway host.docker.internal resolves to there), which only this
311
+ * machine's containers reach. On macOS Docker Desktop delivers
312
+ * host.docker.internal to loopback, so the default bind serves the edge.
313
+ */
314
+ export function bindRefusal(
315
+ bind: string,
316
+ opts: { allowPlainHttpRemote: boolean; fronted: boolean },
317
+ ): string | null {
318
+ if (isLoopbackBind(bind) || opts.allowPlainHttpRemote) return null;
319
+ if (opts.fronted && isDockerBridgeAddress(bind)) return null;
320
+ return (
321
+ `Refusing to listen on ${bind}: it is reachable beyond this machine, ` +
322
+ "and this server's own port answers plain HTTP there, so a session " +
323
+ "cookie would cross the network in clear text, even with the edge in " +
324
+ "front. The edge reaches Den on loopback (or, on Linux, on the Docker " +
325
+ "bridge address: DEN_BIND_HOST=172.17.0.1). To accept plain HTTP from " +
326
+ "other machines, set web.allow_plain_http_remote: true in " +
327
+ "config/local/user.yml (this machine's file; the distribution config's " +
328
+ "is ignored)."
329
+ );
330
+ }
331
+
332
+ /**
333
+ * Why a sign-in is asked for. The request checks ask only beyond loopback
334
+ * (`den`); `terminal` and `agents` are kept for the page's wording.
335
+ */
336
+ export type SigninScope = "terminal" | "agents" | "den";
337
+
338
+ /** What signin_required says, by why it was asked for. */
339
+ export function signinMessage(
340
+ scope: SigninScope,
341
+ cli: string,
342
+ title: string,
343
+ ): string {
344
+ const what =
345
+ scope === "terminal"
346
+ ? "use the terminal"
347
+ : scope === "agents"
348
+ ? "start agents"
349
+ : `use ${title}`;
350
+ return `Sign in to ${what}: run \`${cli} web login\` and open the link.`;
351
+ }
352
+
353
+ export function checkRequest(
354
+ req: http.IncomingMessage,
355
+ ctx: GuardContext,
356
+ cli = "den",
357
+ title = "Den",
358
+ ): GuardResult {
359
+ const named = hostAllowed(header(req, "host"), ctx);
360
+ // The edge hostname from a peer that isn't the edge: not the edge.
361
+ const via =
362
+ named === "edge" && ctx.peerIsEdge && !ctx.peerIsEdge(req) ? null : named;
363
+ if (!via)
364
+ return {
365
+ ok: false,
366
+ status: 421,
367
+ code: "misdirected",
368
+ error:
369
+ "This server answers only to its own host names (localhost, " +
370
+ "127.0.0.1, [::1] with its port, or its edge hostname).",
371
+ };
372
+ const peerLoopback = ctx.peerIsLoopback ? ctx.peerIsLoopback(req) : true;
373
+ const beyond = via !== "loopback" || !peerLoopback;
374
+
375
+ let principal: Principal | null = null;
376
+ const auth = header(req, "authorization");
377
+ const bearer = auth ? /^Bearer\s+(\S+)\s*$/i.exec(auth) : null;
378
+ if (bearer) {
379
+ const proved = ctx.verifyBearer(bearer[1]);
380
+ if (proved === "revoked")
381
+ return {
382
+ ok: false,
383
+ status: 401,
384
+ code: "token_revoked",
385
+ error:
386
+ "This agent's token was revoked (`web logout --all`): restart or resume the agent to get a new one.",
387
+ };
388
+ if (!proved)
389
+ return {
390
+ ok: false,
391
+ status: 401,
392
+ code: "unauthorized",
393
+ error: bearer[1].startsWith("den_at_")
394
+ ? "This agent token is not valid here: its agent was stopped or resumed, or its session ended."
395
+ : `The bearer token is not valid here; \`${cli} web token --print\` prints the current one.`,
396
+ };
397
+ principal =
398
+ typeof proved === "string" ? { kind: "person", actor: proved } : proved;
399
+ }
400
+ const session = principal ? null : (ctx.session?.(req) ?? null);
401
+
402
+ const method = (req.method ?? "GET").toUpperCase();
403
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
404
+ const cls: RouteClass =
405
+ routeClass(method, pathname) ??
406
+ (MUTATING.has(method) || isUpgrade(req) ? "mutating" : "readonly");
407
+
408
+ if (principal?.kind === "agent") {
409
+ const refused = agentRefusal(principal, method, pathname, cls);
410
+ if (refused)
411
+ return {
412
+ ok: false,
413
+ status: 403,
414
+ code: "agent_forbidden",
415
+ error: refused,
416
+ };
417
+ }
418
+
419
+ // §4: a session (or a bearer) beyond loopback; on loopback, never.
420
+ if (!principal && !session && !SIGNIN_FREE.includes(pathname) && beyond) {
421
+ const scope: SigninScope = "den";
422
+ return {
423
+ ok: false,
424
+ status: 401,
425
+ code: "signin_required",
426
+ error: signinMessage(scope, cli, title),
427
+ scope,
428
+ via,
429
+ beyond,
430
+ };
431
+ }
432
+ const ok = (p: Principal | null): GuardResult => ({
433
+ ok: true,
434
+ principal: p,
435
+ via,
436
+ beyond,
437
+ session,
438
+ });
439
+ const browser: Principal | null = session
440
+ ? { kind: "person-browser", session: session.key }
441
+ : null;
442
+
443
+ if (!MUTATING.has(method) && !isUpgrade(req)) {
444
+ if (isMutatingGet(method, pathname)) {
445
+ const refused = mutatingGetRefusal(req, ctx, principal, cli);
446
+ if (refused) return refused;
447
+ }
448
+ return ok(principal ?? browser);
449
+ }
450
+
451
+ const origin = header(req, "origin");
452
+ if (origin === undefined) {
453
+ if (principal) return ok(principal);
454
+ return {
455
+ ok: false,
456
+ status: 403,
457
+ code: "origin_rejected",
458
+ error:
459
+ "A request that changes something needs an Origin from this server, " +
460
+ `or \`Authorization: Bearer $(${cli} web token --print)\`.`,
461
+ };
462
+ }
463
+ if (!originAllowed(origin, ctx))
464
+ return {
465
+ ok: false,
466
+ status: 403,
467
+ code: "origin_rejected",
468
+ error: "Requests from another site can't change anything here.",
469
+ };
470
+ // A valid bearer is a CLI's or an agent's call: no CSRF to prove.
471
+ if (principal) return ok(principal);
472
+ // A browser's WebSocket can't send a header: on an upgrade the token
473
+ // rides as a subprotocol, `den.csrf.<token>` (AG-4a-term's io).
474
+ const csrf =
475
+ header(req, CSRF_HEADER) ??
476
+ (isUpgrade(req) ? csrfFromProtocols(req) : undefined);
477
+ // Signed in: the session's own token; else (loopback) this start's.
478
+ const want = session ? session.csrf : ctx.csrfToken;
479
+ if (!csrf || !same(csrf, want))
480
+ return {
481
+ ok: false,
482
+ status: 403,
483
+ code: "csrf",
484
+ error:
485
+ "The request is missing this page's CSRF token; reload the page and try again.",
486
+ };
487
+ return ok(browser ?? { kind: "person-browser" });
488
+ }
489
+
490
+ /** Routes an agent's token never reaches, whatever the method (SEC-1 §5). */
491
+ export const AGENT_FORBIDDEN: readonly string[] = [
492
+ // web.secret's management and the browser sign-in: an agent mints nothing.
493
+ "/api/web/*",
494
+ "/auth",
495
+ "/signin",
496
+ "/api/session/logout",
497
+ ];
498
+
499
+ /**
500
+ * Routes an agent's token may read but never change: the lock (R8).
501
+ * Privileged actions stay with people (AG-5); its status is a read.
502
+ */
503
+ export const AGENT_READ_ONLY: readonly string[] = [
504
+ "/api/sudo-password",
505
+ "/api/sudo-password/*",
506
+ ];
507
+
508
+ /** `/api/sessions/<id>/…`: a session's own routes (AG-4a-term's io). */
509
+ const SESSION_ROUTE = /^\/api\/sessions\/([^/]+)(?:\/|$)/;
510
+
511
+ /**
512
+ * Why an agent's token can't make this request, or null (SEC-1 §5):
513
+ * - a route in AGENT_FORBIDDEN;
514
+ * - a shell-grade route, unless it is its own session's
515
+ * (`/api/sessions/<its session id or name>/…`): agent start and resume
516
+ * mint tokens, the browser sessions are a person's.
517
+ * Seam (WS-3a, 0.4.2): the route's workspace against `p.workspace` and
518
+ * `p.grants` is checked here; nothing is yet.
519
+ */
520
+ export function agentRefusal(
521
+ p: AgentPrincipal,
522
+ method: string,
523
+ pathname: string,
524
+ cls: RouteClass,
525
+ ): string | null {
526
+ if (
527
+ AGENT_FORBIDDEN.some((r) => pathMatches(r, pathname)) ||
528
+ (method !== "GET" &&
529
+ method !== "HEAD" &&
530
+ AGENT_READ_ONLY.some((r) => pathMatches(r, pathname)))
531
+ )
532
+ return `${p.actor} can't use ${pathname}: signing in, web.secret and the lock are a person's.`;
533
+ const m = SESSION_ROUTE.exec(pathname);
534
+ if (m) {
535
+ let id = m[1];
536
+ try {
537
+ id = decodeURIComponent(id);
538
+ } catch {
539
+ /* compared as given */
540
+ }
541
+ if (id === p.session.id || id === p.session.name) return null;
542
+ return `${p.actor} can use its own session only, not ${id}.`;
543
+ }
544
+ if (cls === "shell-grade")
545
+ return `${p.actor} can't ${method === "GET" ? "open" : "use"} ${pathname}: starting or resuming an agent, and the browser sessions, are a person's.`;
546
+ return workspaceRefusal(p, pathname);
547
+ }
548
+
549
+ /** WS-3a's seam (0.4.2): an agent's route outside its workspace. Not yet. */
550
+ function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
551
+ return null;
552
+ }
553
+
554
+ /**
555
+ * A GET that runs or changes something (route-classes.ts: `mutating` or
556
+ * `shell-grade`; they stream over GET because an EventSource can't POST or
557
+ * send a header). A cross-site `<img src>` or `fetch(…, { mode: "no-cors" })`
558
+ * is a GET too, so these pass only:
559
+ * - from this server's own pages: Sec-Fetch-Site same-origin (or none, a
560
+ * URL the person typed), and an Origin, if any, that is allowed;
561
+ * - or with neither header (the CLI, curl, MCP) and a valid bearer.
562
+ * Sec-Fetch-Site same-site or cross-site: 403 `cross_site` (another port on
563
+ * localhost is same-site, not same-origin).
564
+ */
565
+ function mutatingGetRefusal(
566
+ req: http.IncomingMessage,
567
+ ctx: GuardContext,
568
+ principal: Principal | null,
569
+ cli: string,
570
+ ): Extract<GuardResult, { ok: false }> | null {
571
+ const site = header(req, "sec-fetch-site");
572
+ const origin = header(req, "origin");
573
+ if (site !== undefined && site !== "same-origin" && site !== "none")
574
+ return {
575
+ ok: false,
576
+ status: 403,
577
+ code: "cross_site",
578
+ error: "Another site can't start or change anything here.",
579
+ };
580
+ if (origin !== undefined && !originAllowed(origin, ctx))
581
+ return {
582
+ ok: false,
583
+ status: 403,
584
+ code: "origin_rejected",
585
+ error: "Requests from another site can't start or change anything here.",
586
+ };
587
+ if (site === undefined && origin === undefined && !principal)
588
+ return {
589
+ ok: false,
590
+ status: 403,
591
+ code: "origin_rejected",
592
+ error:
593
+ "This request runs something: it needs this server's own page, or " +
594
+ `\`Authorization: Bearer $(${cli} web token --print)\`.`,
595
+ };
596
+ return null;
597
+ }
598
+
599
+ /**
600
+ * `GET /api/session` answers only a same-origin caller: no Origin or an
601
+ * allowed one, and no cross-site Sec-Fetch-Site.
602
+ */
603
+ export function sessionRequestAllowed(
604
+ req: http.IncomingMessage,
605
+ ctx: Pick<GuardContext, "port" | "loopbackNames" | "edgeHosts" | "lanHosts">,
606
+ ): boolean {
607
+ const origin = header(req, "origin");
608
+ if (origin !== undefined && !originAllowed(origin, ctx)) return false;
609
+ const site = header(req, "sec-fetch-site");
610
+ return site === undefined || site === "same-origin" || site === "none";
611
+ }
612
+
613
+ const PRINCIPALS = new WeakMap<http.IncomingMessage, Principal>();
614
+
615
+ /** Remember what the request proved (for the audit trail). */
616
+ export function setPrincipal(
617
+ req: http.IncomingMessage,
618
+ p: Principal | null,
619
+ ): void {
620
+ if (p) PRINCIPALS.set(req, p);
621
+ }
622
+
623
+ /** The agent a request's actor token proves (SEC-1c), if any. */
624
+ export function agentPrincipalOf(
625
+ req: http.IncomingMessage,
626
+ ): AgentPrincipal | null {
627
+ const p = PRINCIPALS.get(req);
628
+ return p?.kind === "agent" ? p : null;
629
+ }
630
+
631
+ /**
632
+ * Who the request's credential proves it is, for the audit trail:
633
+ * `agent:<id>` for an actor token, `person:<user>` for a person token,
634
+ * `person:<os user>` for a signed-in browser (a session is the OS user's:
635
+ * only they can mint its login code). Undefined with no credential. Never
636
+ * anything a header the caller sets claims.
637
+ */
638
+ export function principalOf(req: http.IncomingMessage): string | undefined {
639
+ const p = PRINCIPALS.get(req);
640
+ if (p?.kind === "person" || p?.kind === "agent") return p.actor;
641
+ if (p?.kind === "person-browser" && p.session) return `person:${osUser()}`;
642
+ return undefined;
643
+ }
644
+
645
+ /**
646
+ * The audit actor of a request (recordAction's first argument), from its
647
+ * credential (SEC-1c):
648
+ * - an actor token: that agent and its workspace, verified; the MCP
649
+ * client's self-reported name and version are labels only;
650
+ * - anything else: null (the person), unless the caller sent x-den-actor
651
+ * headers. Those are never the actor: they are kept as an unverified
652
+ * claim (AuditEntry.claimed), whatever credential came with them.
653
+ */
654
+ export function auditActorOf(req: http.IncomingMessage): Actor {
655
+ const claimed = actorFromHeaders(req.headers);
656
+ const agent = agentPrincipalOf(req);
657
+ if (agent) {
658
+ const actor: AgentActor = {
659
+ kind: "agent",
660
+ client: claimed?.client ?? "unknown",
661
+ clientVersion: claimed?.clientVersion ?? "unknown",
662
+ mcpPid: claimed?.mcpPid ?? null,
663
+ session: claimed?.session ?? "",
664
+ agent: agent.actor,
665
+ workspace: agent.workspace,
666
+ };
667
+ return actor;
668
+ }
669
+ return claimed ? { ...claimed, unverified: true } : null;
670
+ }