pilotswarm 0.5.13 → 0.5.14

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 (42) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +12 -0
  3. package/mcp/dist/src/context.d.ts +13 -0
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +20 -0
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/tools/capabilities.d.ts +3 -0
  8. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/capabilities.js +8 -0
  10. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +114 -0
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/package.json +3 -2
  15. package/tui/src/app.js +19 -2
  16. package/tui/src/auth/cli.js +13 -0
  17. package/tui/src/node-sdk-transport.js +53 -8
  18. package/tui/tui-splash-mobile.txt +5 -7
  19. package/tui/tui-splash.txt +13 -9
  20. package/ui/core/src/commands.js +2 -0
  21. package/ui/core/src/controller.js +275 -6
  22. package/ui/core/src/history.js +19 -1
  23. package/ui/core/src/reducer.js +4 -0
  24. package/ui/core/src/selectors.js +139 -14
  25. package/ui/core/src/themes/helpers.js +4 -0
  26. package/ui/react/src/components.js +95 -6
  27. package/ui/react/src/web-app.js +555 -117
  28. package/web/api/router.js +7 -6
  29. package/web/api/ws.js +9 -0
  30. package/web/auth/index.js +5 -0
  31. package/web/auth/providers/dev.js +119 -0
  32. package/web/authz.js +142 -0
  33. package/web/dist/assets/index-BnxC8cNG.js +24 -0
  34. package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
  35. package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
  36. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  37. package/web/dist/index.html +3 -4
  38. package/web/runtime.js +453 -9
  39. package/web/server.js +2 -2
  40. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  41. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  42. package/web/dist/assets/react-C9iQPS2h.js +0 -1
@@ -0,0 +1 @@
1
+
@@ -4,11 +4,10 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
6
6
  <title>PilotSwarm</title>
7
- <script type="module" crossorigin src="/assets/index-bQ2QInMX.js"></script>
8
- <link rel="modulepreload" crossorigin href="/assets/pilotswarm-DRs6o-lA.js">
9
- <link rel="modulepreload" crossorigin href="/assets/react-C9iQPS2h.js">
7
+ <script type="module" crossorigin src="/assets/index-BnxC8cNG.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/assets/pilotswarm-NE7H63ha.js">
10
9
  <link rel="modulepreload" crossorigin href="/assets/msal-CytV9RFv.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-oldX95Tp.css">
10
+ <link rel="stylesheet" crossorigin href="/assets/index-Bx6KHIaj.css">
12
11
  </head>
13
12
  <body>
14
13
  <div id="root"></div>
package/web/runtime.js CHANGED
@@ -1,4 +1,13 @@
1
1
  import { NodeSdkTransport } from "pilotswarm/host";
2
+ import {
3
+ loadAuthzConfig,
4
+ normalizeVisibility,
5
+ getMethodAccess,
6
+ evaluateSessionAccess,
7
+ relationFor,
8
+ forbiddenError,
9
+ notFoundError,
10
+ } from "./authz.js";
2
11
 
3
12
  function normalizeParams(params) {
4
13
  return params && typeof params === "object" ? params : {};
@@ -82,12 +91,278 @@ function requireUserPrincipal(authContext, methodName) {
82
91
  return principal;
83
92
  }
84
93
 
94
+ // Break-glass audit coverage: every non-read session op, plus the reads that
95
+ // expose content (transcript, artifacts, history). Status polling and list
96
+ // metadata are excluded to keep the audit stream signal-dense.
97
+ const BREAK_GLASS_AUDITED = {
98
+ any: true,
99
+ getSessionEvents: true,
100
+ getSessionEventsBefore: true,
101
+ downloadArtifact: true,
102
+ readArtifactBase64: true,
103
+ getExecutionHistory: true,
104
+ getLatestResponse: true,
105
+ listSessionShares: true,
106
+ };
107
+
85
108
  export class PortalRuntime {
86
109
  constructor({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser } = {}) {
87
110
  this.transport = new NodeSdkTransport({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser });
88
111
  this.mode = mode;
89
112
  this.started = false;
90
113
  this.startPromise = null;
114
+ this.authz = loadAuthzConfig();
115
+ // Throttle repeated break-glass audit rows for the same actor+session
116
+ // (the portal polls events continuously while a session is open).
117
+ this._breakGlassSeen = new Map(); // key -> expiry epoch ms
118
+ }
119
+
120
+ // ── Authorization (security model) ──────────────────────────────────
121
+
122
+ _recordAudit(entry) {
123
+ if (typeof this.transport.recordAuthzAudit !== "function") return;
124
+ this.transport.recordAuthzAudit(entry).catch(() => {});
125
+ }
126
+
127
+ _auditActor(authContext) {
128
+ const principal = authContext?.principal;
129
+ return {
130
+ provider: principal?.provider ?? null,
131
+ subject: principal?.subject ?? null,
132
+ display: principal?.displayName ?? principal?.email ?? null,
133
+ };
134
+ }
135
+
136
+ _shouldRecordBreakGlass(actorKey, sessionId) {
137
+ const key = `${actorKey}${sessionId}`;
138
+ const now = Date.now();
139
+ const expiry = this._breakGlassSeen.get(key);
140
+ if (expiry && expiry > now) return false;
141
+ if (this._breakGlassSeen.size > 5000) this._breakGlassSeen.clear();
142
+ this._breakGlassSeen.set(key, now + 15 * 60 * 1000);
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * Gate one dispatched method. Returns { snapshot } (the access snapshot
148
+ * for session-scoped ops, so handlers can reuse it — e.g. sender
149
+ * relation) or throws 403/404. With enforcement off, would-be denials
150
+ * are audited and allowed through (dark launch).
151
+ */
152
+ async _authorizeCall(method, safeParams, authContext, { owner, isAdmin }) {
153
+ const spec = getMethodAccess(method);
154
+ const access = spec?.access || "authed";
155
+
156
+ if (access === "authed" || access === "session:create" || access === "facts:read" || access === "group:list" || access === "session:list") {
157
+ // List/read scoping happens in the case handlers (viewer-scoped
158
+ // catalog paths); creation stamps owner+visibility there too.
159
+ return { snapshot: null };
160
+ }
161
+
162
+ if (access === "fleet:read" || access === "fleet:admin") {
163
+ if (!isAdmin) {
164
+ const reason = access === "fleet:admin"
165
+ ? "This operation requires the admin role."
166
+ : "Fleet-wide observability requires the admin role.";
167
+ this._recordAudit({
168
+ actor: this._auditActor(authContext),
169
+ action: method,
170
+ decision: this.authz.enforce ? "deny" : "would_deny",
171
+ reason,
172
+ });
173
+ if (this.authz.enforce || access === "fleet:admin") {
174
+ // fleet:admin has always been enforced (op.admin) —
175
+ // keep it hard regardless of the dark-launch flag.
176
+ throw forbiddenError(reason);
177
+ }
178
+ }
179
+ return { snapshot: null };
180
+ }
181
+
182
+ if (access === "facts:write") {
183
+ await this._authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin });
184
+ return { snapshot: null };
185
+ }
186
+
187
+ if (access === "group:manage") {
188
+ await this._authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin });
189
+ return { snapshot: null };
190
+ }
191
+
192
+ if (access === "authz:audit") {
193
+ const sessionId = safeParams.sessionId ? String(safeParams.sessionId) : null;
194
+ if (isAdmin) return { snapshot: null };
195
+ if (!sessionId) throw forbiddenError("Fleet-wide audit requires the admin role. Pass sessionId to read audit for a session you own.");
196
+ // Owner-only, and hard-enforced (session:share) so a missing/deleted
197
+ // session id can't open the audit trail during dark-launch.
198
+ return this._gateSession(method, "session:share", sessionId, authContext, { owner, isAdmin });
199
+ }
200
+
201
+ if (access === "session:copy") {
202
+ const [from, to] = await Promise.all([
203
+ this._gateSession(method, "session:read", safeParams.fromSessionId, authContext, { owner, isAdmin }),
204
+ this._gateSession(method, "session:write", safeParams.toSessionId, authContext, { owner, isAdmin }),
205
+ ]);
206
+ return { snapshot: to.snapshot ?? from.snapshot };
207
+ }
208
+
209
+ if (access.startsWith("session:")) {
210
+ const sessionId = safeParams[spec.sessionParam];
211
+ return this._gateSession(method, access, sessionId, authContext, { owner, isAdmin });
212
+ }
213
+
214
+ return { snapshot: null };
215
+ }
216
+
217
+ /** Whether the deployment's transport can resolve access snapshots at all. */
218
+ _accessSnapshotSupported() {
219
+ return typeof this.transport.getSessionAccess === "function";
220
+ }
221
+
222
+ async _getAccessSnapshot(sessionId, owner) {
223
+ if (!sessionId || !this._accessSnapshotSupported()) return null;
224
+ return this.transport.getSessionAccess(String(sessionId), {
225
+ provider: owner?.provider ?? "",
226
+ subject: owner?.subject ?? "",
227
+ });
228
+ }
229
+
230
+ async _gateSession(method, accessClass, sessionId, authContext, { owner, isAdmin }) {
231
+ // session:share is a brand-new capability with no pre-model behavior
232
+ // to preserve, so it is enforced even during the ownership dark-launch
233
+ // — otherwise a user could pre-plant a durable grant that survives the
234
+ // flip to enforce (adversarial review HIGH-2).
235
+ const effectiveEnforce = this.authz.enforce || accessClass === "session:share";
236
+ const hasSessionId = sessionId != null && String(sessionId).trim() !== "";
237
+
238
+ // HIGH-1: a supplied-but-unresolvable id (missing OR soft-deleted —
239
+ // cms_get_session_access returns no row for either) must not open the
240
+ // gate. Only genuinely id-less ops get the permissive null path. Skip
241
+ // when the transport can't resolve snapshots at all (legacy/no-auth).
242
+ if (hasSessionId && this._accessSnapshotSupported()) {
243
+ const snapshot = await this._getAccessSnapshot(sessionId, owner);
244
+ if (!snapshot) {
245
+ this._recordAudit({
246
+ actor: this._auditActor(authContext),
247
+ action: method,
248
+ sessionId: String(sessionId),
249
+ decision: effectiveEnforce ? "deny" : "would_deny",
250
+ reason: "session not found or deleted",
251
+ });
252
+ if (!effectiveEnforce) return { snapshot: null };
253
+ throw notFoundError();
254
+ }
255
+ return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
256
+ }
257
+
258
+ const snapshot = await this._getAccessSnapshot(sessionId, owner);
259
+ return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
260
+ }
261
+
262
+ _decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce }) {
263
+ const decision = evaluateSessionAccess(accessClass, snapshot, {
264
+ isAdmin,
265
+ systemReadable: this.authz.systemVisibility === "read",
266
+ });
267
+
268
+ if (decision.allowed) {
269
+ if (decision.breakGlass && BREAK_GLASS_AUDITED[accessClass !== "session:read" ? "any" : method]) {
270
+ const actor = this._auditActor(authContext);
271
+ const actorKey = `${actor.provider}/${actor.subject}`;
272
+ if (this._shouldRecordBreakGlass(actorKey, String(sessionId))) {
273
+ this._recordAudit({
274
+ actor,
275
+ action: method,
276
+ sessionId: String(sessionId),
277
+ decision: "break_glass",
278
+ reason: "Admin access to a private session owned by another user",
279
+ });
280
+ }
281
+ }
282
+ return { snapshot };
283
+ }
284
+
285
+ this._recordAudit({
286
+ actor: this._auditActor(authContext),
287
+ action: method,
288
+ sessionId: sessionId ? String(sessionId) : null,
289
+ decision: effectiveEnforce ? "deny" : "would_deny",
290
+ reason: decision.notFound ? "not visible" : decision.reason,
291
+ });
292
+
293
+ if (!effectiveEnforce) return { snapshot };
294
+ throw decision.notFound ? notFoundError() : forbiddenError(decision.reason);
295
+ }
296
+
297
+ /**
298
+ * Facts write containment: non-admin callers may write/delete shared
299
+ * facts (the deployment's collaboration memory) and facts of sessions
300
+ * they can WRITE; anything else — in particular pattern deletes over
301
+ * other sessions' private facts — is denied.
302
+ */
303
+ async _authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin }) {
304
+ if (isAdmin) return;
305
+ const inputs = Array.isArray(safeParams.input) ? safeParams.input : [safeParams.input];
306
+ for (const input of inputs) {
307
+ const sessionId = typeof input?.sessionId === "string" && input.sessionId.trim() ? input.sessionId.trim() : null;
308
+ const scopeKey = typeof input?.scopeKey === "string" ? input.scopeKey : "";
309
+ const sessionScopeFromKey = scopeKey.startsWith("session:") ? scopeKey.split(":")[1] || null : null;
310
+ const targetSession = sessionId || sessionScopeFromKey;
311
+ const isSharedScope = !targetSession && (input?.shared === true || scopeKey.startsWith("shared:") || input?.scope === "shared" || (!scopeKey && !sessionId));
312
+ if (isSharedScope) continue;
313
+ await this._gateSession(method, "session:write", targetSession, authContext, { owner, isAdmin });
314
+ }
315
+ }
316
+
317
+ async _authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin }) {
318
+ if (isAdmin) return;
319
+ const groupId = safeParams.groupId ? String(safeParams.groupId) : null;
320
+ if (groupId) {
321
+ const groups = await this.transport.listSessionGroups().catch(() => []);
322
+ const group = (groups || []).find((g) => g.groupId === groupId);
323
+ if (group) {
324
+ const groupOwner = normalizeOwnerPrincipal(group.owner);
325
+ const allowed = !groupOwner || (owner && groupOwner.provider === owner.provider && groupOwner.subject === owner.subject);
326
+ if (!allowed) {
327
+ this._recordAudit({
328
+ actor: this._auditActor(authContext),
329
+ action: method,
330
+ target: `group:${groupId}`,
331
+ decision: this.authz.enforce ? "deny" : "would_deny",
332
+ reason: "group owned by another user",
333
+ });
334
+ if (this.authz.enforce) {
335
+ throw forbiddenError("Only the group owner or an admin can manage this group.");
336
+ }
337
+ }
338
+ }
339
+ }
340
+ // Assign/move (including ungroup, groupId=null) also mutates the
341
+ // sessions themselves — gate each as session:manage so a user can't
342
+ // pull another user's session out of (or into) a group
343
+ // (adversarial review MEDIUM-2).
344
+ const sessionIds = Array.isArray(safeParams.sessionIds) ? safeParams.sessionIds : [];
345
+ for (const sessionId of sessionIds) {
346
+ await this._gateSession(method, "session:manage", sessionId, authContext, { owner, isAdmin });
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Viewer descriptor for viewer-scoped listing, or null for unfiltered.
352
+ * A non-admin without a resolvable identity in enforce mode gets a viewer
353
+ * that matches no owner and no targeted share, so they see only
354
+ * deployment-shared trees (shared_read/shared_write are visible to every
355
+ * admitted user by design) — never the unfiltered fleet or another user's
356
+ * private sessions (adversarial review LOW-1 / NEW-5).
357
+ */
358
+ _listViewer(owner, isAdmin) {
359
+ if (isAdmin || !this.authz.enforce) return null;
360
+ if (!owner) return { provider: "nomatch", subject: "nomatch", systemVisible: false };
361
+ return {
362
+ provider: owner.provider,
363
+ subject: owner.subject,
364
+ systemVisible: this.authz.systemVisibility === "read",
365
+ };
91
366
  }
92
367
 
93
368
  async start() {
@@ -160,6 +435,14 @@ export class PortalRuntime {
160
435
  sessionCreationPolicy: typeof this.transport.getSessionCreationPolicy === "function"
161
436
  ? this.transport.getSessionCreationPolicy()
162
437
  : null,
438
+ // Ownership/visibility posture (security model) so clients (portal,
439
+ // MCP, TUI) can explain why a session isn't listed or a send was
440
+ // refused, and default the share UI correctly.
441
+ authz: {
442
+ ownershipEnforced: this.authz.enforce,
443
+ defaultVisibility: this.authz.defaultVisibility,
444
+ systemVisibility: this.authz.systemVisibility,
445
+ },
163
446
  };
164
447
  }
165
448
 
@@ -172,11 +455,24 @@ export class PortalRuntime {
172
455
  // visibility so a plain caller cannot read another session's private facts.
173
456
  const role = authContext?.authorization?.role;
174
457
  const isAdmin = role === "admin" || role === "anonymous";
458
+ // Ownership/visibility gate — the single enforcement point for both
459
+ // the generated /api/v1 routes and the legacy /api/rpc dispatcher.
460
+ const gate = await this._authorizeCall(method, safeParams, authContext, { owner, isAdmin });
461
+ const listViewer = this._listViewer(owner, isAdmin);
175
462
  switch (method) {
176
463
  case "listSessions":
177
- return this.transport.listSessions();
178
- case "listSessionGroups":
179
- return this.transport.listSessionGroups();
464
+ return listViewer && typeof this.transport.listSessionsVisible === "function"
465
+ ? this.transport.listSessionsVisible(listViewer)
466
+ : this.transport.listSessions();
467
+ case "listSessionGroups": {
468
+ const groups = await this.transport.listSessionGroups();
469
+ if (!listViewer) return groups;
470
+ // Non-admin group listing: own groups plus ownerless ones.
471
+ return (groups || []).filter((group) => {
472
+ const groupOwner = normalizeOwnerPrincipal(group?.owner);
473
+ return !groupOwner || (groupOwner.provider === owner.provider && groupOwner.subject === owner.subject);
474
+ });
475
+ }
180
476
  case "createSessionGroup":
181
477
  return this.transport.createSessionGroup({
182
478
  ...(safeParams.input || {}),
@@ -193,7 +489,10 @@ export class PortalRuntime {
193
489
  case "listChildOutcomes":
194
490
  return this.transport.listChildOutcomes(safeParams.parentSessionId);
195
491
  case "listSessionsPage":
196
- return this.transport.listSessionsPage(normalizeSessionPageOptions(safeParams));
492
+ return this.transport.listSessionsPage({
493
+ ...normalizeSessionPageOptions(safeParams),
494
+ ...(listViewer ? { viewer: listViewer } : {}),
495
+ });
197
496
  case "getSession":
198
497
  return this.transport.getSession(safeParams.sessionId);
199
498
  case "getOrchestrationStats":
@@ -364,6 +663,7 @@ export class PortalRuntime {
364
663
  contextTier: safeParams.contextTier,
365
664
  groupId: safeParams.groupId,
366
665
  owner,
666
+ visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
367
667
  });
368
668
  case "createSessionForAgent":
369
669
  return this.transport.createSessionForAgent(safeParams.agentName, {
@@ -376,17 +676,105 @@ export class PortalRuntime {
376
676
  initialPrompt: safeParams.initialPrompt,
377
677
  groupId: safeParams.groupId,
378
678
  owner,
679
+ visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
379
680
  });
380
681
  case "listCreatableAgents":
381
682
  return this.transport.listCreatableAgents();
382
683
  case "getSessionCreationPolicy":
383
684
  return this.transport.getSessionCreationPolicy();
384
685
  case "sendMessage":
385
- return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, safeParams.options);
686
+ return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, {
687
+ ...(safeParams.options && typeof safeParams.options === "object" ? safeParams.options : {}),
688
+ // Server-stamped; a client-supplied options.sender is overwritten.
689
+ sender: this._buildSender(authContext, gate.snapshot, { isAdmin, origin: safeParams.options?.origin }),
690
+ });
386
691
  case "sendAnswer":
387
- return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer);
692
+ return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer, {
693
+ sender: this._buildSender(authContext, gate.snapshot, { isAdmin }),
694
+ });
388
695
  case "sendSessionEvent":
389
696
  return this.transport.sendSessionEvent(safeParams.sessionId, safeParams.eventName, safeParams.data);
697
+
698
+ // ── Session sharing (security model) ────────────────────────
699
+ case "getSessionAccess": {
700
+ const snapshot = gate.snapshot ?? await this._getAccessSnapshot(safeParams.sessionId, owner);
701
+ if (!snapshot) {
702
+ throw notFoundError();
703
+ }
704
+ const relation = snapshot.viewerIsOwner ? "owner" : (isAdmin ? "admin" : (snapshot.viewerShareAccess ? "collaborator" : "none"));
705
+ const canWrite = isAdmin || snapshot.viewerIsOwner || snapshot.visibility === "shared_write" || snapshot.viewerShareAccess === "write";
706
+ const canManage = isAdmin || snapshot.viewerIsOwner;
707
+ return {
708
+ sessionId: safeParams.sessionId,
709
+ rootSessionId: snapshot.rootSessionId,
710
+ isSystem: snapshot.isSystem,
711
+ visibility: snapshot.visibility,
712
+ owner: snapshot.owner,
713
+ relation,
714
+ canWrite: snapshot.isSystem ? isAdmin : canWrite,
715
+ canManage: snapshot.isSystem ? isAdmin : canManage,
716
+ enforced: this.authz.enforce,
717
+ };
718
+ }
719
+ case "setSessionVisibility": {
720
+ const visibility = normalizeVisibility(safeParams.visibility, null);
721
+ if (!visibility) {
722
+ throw Object.assign(new Error("visibility must be private | shared_read | shared_write"), { code: "INVALID_REQUEST" });
723
+ }
724
+ await this.transport.setSessionVisibility(safeParams.sessionId, visibility);
725
+ this._recordAudit({
726
+ actor: this._auditActor(authContext),
727
+ action: "setSessionVisibility",
728
+ sessionId: String(safeParams.sessionId),
729
+ decision: "share_change",
730
+ reason: `visibility=${visibility}`,
731
+ });
732
+ return { sessionId: safeParams.sessionId, visibility };
733
+ }
734
+ case "grantSessionShare": {
735
+ const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
736
+ const access = safeParams.access === "write" ? "write" : safeParams.access === "read" ? "read" : null;
737
+ if (!grantee.provider || !grantee.subject || !access) {
738
+ throw Object.assign(new Error("grantSessionShare requires user { provider, subject } and access read|write"), { code: "INVALID_REQUEST" });
739
+ }
740
+ await this.transport.grantSessionShare(safeParams.sessionId, grantee, access, owner);
741
+ this._recordAudit({
742
+ actor: this._auditActor(authContext),
743
+ action: "grantSessionShare",
744
+ sessionId: String(safeParams.sessionId),
745
+ target: `${grantee.provider}/${grantee.subject}`,
746
+ decision: "share_change",
747
+ reason: `access=${access}`,
748
+ });
749
+ return { sessionId: safeParams.sessionId, granted: { ...grantee, access } };
750
+ }
751
+ case "revokeSessionShare": {
752
+ const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
753
+ if (!grantee.provider || !grantee.subject) {
754
+ throw Object.assign(new Error("revokeSessionShare requires user { provider, subject }"), { code: "INVALID_REQUEST" });
755
+ }
756
+ await this.transport.revokeSessionShare(safeParams.sessionId, grantee);
757
+ this._recordAudit({
758
+ actor: this._auditActor(authContext),
759
+ action: "revokeSessionShare",
760
+ sessionId: String(safeParams.sessionId),
761
+ target: `${grantee.provider}/${grantee.subject}`,
762
+ decision: "share_change",
763
+ reason: "revoked",
764
+ });
765
+ return { sessionId: safeParams.sessionId, revoked: grantee };
766
+ }
767
+ case "listSessionShares":
768
+ return this.transport.listSessionShares(safeParams.sessionId);
769
+ case "listKnownUsers":
770
+ return typeof this.transport.listKnownUsers === "function"
771
+ ? this.transport.listKnownUsers({ limit: safeParams.limit })
772
+ : [];
773
+ case "listAuthzAudit":
774
+ return this.transport.listAuthzAudit({
775
+ limit: safeParams.limit,
776
+ sessionId: safeParams.sessionId ?? null,
777
+ });
390
778
  case "getSessionStatus":
391
779
  return this.transport.getSessionStatus(safeParams.sessionId);
392
780
  case "waitForStatusChange": {
@@ -481,19 +869,42 @@ export class PortalRuntime {
481
869
  }
482
870
  }
483
871
 
484
- async downloadArtifact(sessionId, filename) {
872
+ /**
873
+ * Server-stamped message sender: identity from the validated auth
874
+ * context, relation from the access snapshot. Never trusts
875
+ * client-supplied identity fields; `origin` is client-declared display
876
+ * metadata only.
877
+ */
878
+ _buildSender(authContext, snapshot, { isAdmin = false, origin } = {}) {
879
+ const principal = normalizeSessionOwner(authContext);
880
+ if (!principal) return undefined;
881
+ const allowedOrigins = new Set(["portal", "tui", "mcp", "api"]);
882
+ return {
883
+ kind: "user",
884
+ provider: principal.provider,
885
+ subject: principal.subject,
886
+ display: principal.displayName || principal.email || principal.subject,
887
+ relation: relationFor(snapshot, { isAdmin }),
888
+ origin: allowedOrigins.has(origin) ? origin : "api",
889
+ };
890
+ }
891
+
892
+ async downloadArtifact(sessionId, filename, authContext = null) {
485
893
  await this.start();
894
+ await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
486
895
  return this.transport.downloadArtifact(sessionId, filename);
487
896
  }
488
897
 
489
- async getArtifactMetadata(sessionId, filename) {
898
+ async getArtifactMetadata(sessionId, filename, authContext = null) {
490
899
  await this.start();
900
+ await this._gateBespokeRead("getArtifactMetadata", sessionId, authContext);
491
901
  if (typeof this.transport.getArtifactMetadata !== "function") return null;
492
902
  return this.transport.getArtifactMetadata(sessionId, filename);
493
903
  }
494
904
 
495
- async downloadArtifactBinary(sessionId, filename) {
905
+ async downloadArtifactBinary(sessionId, filename, authContext = null) {
496
906
  await this.start();
907
+ await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
497
908
  if (typeof this.transport.downloadArtifactBinary === "function") {
498
909
  return this.transport.downloadArtifactBinary(sessionId, filename);
499
910
  }
@@ -509,6 +920,39 @@ export class PortalRuntime {
509
920
  };
510
921
  }
511
922
 
923
+ /** session:read gate for the bespoke (non-dispatched) artifact routes. */
924
+ async _gateBespokeRead(action, sessionId, authContext) {
925
+ const role = authContext?.authorization?.role;
926
+ const isAdmin = role === "admin" || role === "anonymous";
927
+ const owner = normalizeSessionOwner(authContext);
928
+ await this._gateSession(action, "session:read", sessionId, authContext, { owner, isAdmin });
929
+ }
930
+
931
+ /**
932
+ * WebSocket subscription gate (api/ws.js). Throws 403/404 when the
933
+ * caller cannot read the session; audited like every other decision.
934
+ */
935
+ async authorizeSessionSubscribe(sessionId, authContext) {
936
+ await this.start();
937
+ await this._gateBespokeRead("subscribeSession", sessionId, authContext);
938
+ }
939
+
940
+ /** Log tail is fleet-wide observability: admin (or dark-launch). */
941
+ async authorizeLogSubscribe(authContext) {
942
+ const role = authContext?.authorization?.role;
943
+ const isAdmin = role === "admin" || role === "anonymous";
944
+ if (isAdmin) return;
945
+ this._recordAudit({
946
+ actor: this._auditActor(authContext),
947
+ action: "subscribeLogs",
948
+ decision: this.authz.enforce ? "deny" : "would_deny",
949
+ reason: "log tail requires the admin role",
950
+ });
951
+ if (this.authz.enforce) {
952
+ throw forbiddenError("The live log tail requires the admin role.");
953
+ }
954
+ }
955
+
512
956
  subscribeSession(sessionId, handler) {
513
957
  return this.transport.subscribeSession(sessionId, handler);
514
958
  }
package/web/server.js CHANGED
@@ -181,7 +181,7 @@ export async function startServer(opts = {}) {
181
181
  try {
182
182
  const sessionId = req.params.sessionId;
183
183
  const filename = req.params.filename;
184
- const artifact = await runtime.downloadArtifactBinary(sessionId, filename);
184
+ const artifact = await runtime.downloadArtifactBinary(sessionId, filename, req.auth);
185
185
  const contentType = String(artifact?.contentType || "application/octet-stream");
186
186
  res.setHeader("content-type", contentType);
187
187
  res.setHeader("content-disposition", `attachment; filename="${path.basename(filename)}"`);
@@ -196,7 +196,7 @@ export async function startServer(opts = {}) {
196
196
  try {
197
197
  const sessionId = req.params.sessionId;
198
198
  const filename = req.params.filename;
199
- const metadata = await runtime.getArtifactMetadata(sessionId, filename);
199
+ const metadata = await runtime.getArtifactMetadata(sessionId, filename, req.auth);
200
200
  if (!metadata) {
201
201
  res.status(404).json({ ok: false, error: "Artifact not found" });
202
202
  return;