pilotswarm 0.5.13 → 0.5.15

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 (51) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +14 -2
  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/server.d.ts.map +1 -1
  8. package/mcp/dist/src/server.js +5 -1
  9. package/mcp/dist/src/server.js.map +1 -1
  10. package/mcp/dist/src/tools/capabilities.d.ts +4 -0
  11. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/capabilities.js +13 -0
  13. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  14. package/mcp/dist/src/tools/groups.d.ts +5 -4
  15. package/mcp/dist/src/tools/groups.d.ts.map +1 -1
  16. package/mcp/dist/src/tools/groups.js +43 -20
  17. package/mcp/dist/src/tools/groups.js.map +1 -1
  18. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  19. package/mcp/dist/src/tools/sessions.js +118 -2
  20. package/mcp/dist/src/tools/sessions.js.map +1 -1
  21. package/package.json +3 -2
  22. package/tui/src/app.js +19 -2
  23. package/tui/src/auth/cli.js +13 -0
  24. package/tui/src/node-sdk-transport.js +99 -13
  25. package/tui/tui-splash-mobile.txt +5 -7
  26. package/tui/tui-splash.txt +13 -9
  27. package/ui/core/src/commands.js +2 -0
  28. package/ui/core/src/controller.js +454 -35
  29. package/ui/core/src/history.js +19 -1
  30. package/ui/core/src/reducer.js +121 -8
  31. package/ui/core/src/selectors.js +204 -14
  32. package/ui/core/src/state.js +3 -0
  33. package/ui/core/src/themes/helpers.js +4 -0
  34. package/ui/react/src/components.js +95 -6
  35. package/ui/react/src/web-app.js +798 -157
  36. package/web/api/router.js +7 -6
  37. package/web/api/ws.js +9 -0
  38. package/web/auth/index.js +5 -0
  39. package/web/auth/providers/dev.js +119 -0
  40. package/web/authz.js +142 -0
  41. package/web/dist/assets/index-CZizkB5Z.js +24 -0
  42. package/web/dist/assets/index-D9e2TGjO.css +1 -0
  43. package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
  44. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  45. package/web/dist/index.html +3 -4
  46. package/web/runtime.js +553 -37
  47. package/web/server.js +2 -2
  48. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  49. package/web/dist/assets/index-oldX95Tp.css +0 -1
  50. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  51. package/web/dist/assets/react-C9iQPS2h.js +0 -1
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 : {};
@@ -66,10 +75,13 @@ function normalizeOwnerPrincipal(principal) {
66
75
  };
67
76
  }
68
77
 
69
- function ownerKey(owner) {
70
- const provider = String(owner?.provider || "").trim();
71
- const subject = String(owner?.subject || "").trim();
72
- return provider && subject ? `${provider}\u0001${subject}` : null;
78
+ // Placement identity: group placements are keyed off the same principal as
79
+ // owner stamping. No-auth deployments fall back to a shared anonymous user
80
+ // (lazily registered by the placement proc) so grouping keeps working there.
81
+ function placementPrincipal(authContext) {
82
+ const principal = normalizeSessionOwner(authContext);
83
+ if (principal) return { provider: principal.provider, subject: principal.subject };
84
+ return { provider: "anonymous", subject: "anonymous" };
73
85
  }
74
86
 
75
87
  function requireUserPrincipal(authContext, methodName) {
@@ -82,12 +94,351 @@ function requireUserPrincipal(authContext, methodName) {
82
94
  return principal;
83
95
  }
84
96
 
97
+ // Break-glass audit coverage: every non-read session op, plus the reads that
98
+ // expose content (transcript, artifacts, history). Status polling and list
99
+ // metadata are excluded to keep the audit stream signal-dense.
100
+ const BREAK_GLASS_AUDITED = {
101
+ any: true,
102
+ getSessionEvents: true,
103
+ getSessionEventsBefore: true,
104
+ downloadArtifact: true,
105
+ readArtifactBase64: true,
106
+ getExecutionHistory: true,
107
+ getLatestResponse: true,
108
+ listSessionShares: true,
109
+ };
110
+
85
111
  export class PortalRuntime {
86
112
  constructor({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser } = {}) {
87
113
  this.transport = new NodeSdkTransport({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser });
88
114
  this.mode = mode;
89
115
  this.started = false;
90
116
  this.startPromise = null;
117
+ this.authz = loadAuthzConfig();
118
+ // Throttle repeated break-glass audit rows for the same actor+session
119
+ // (the portal polls events continuously while a session is open).
120
+ this._breakGlassSeen = new Map(); // key -> expiry epoch ms
121
+ }
122
+
123
+ // ── Authorization (security model) ──────────────────────────────────
124
+
125
+ _recordAudit(entry) {
126
+ if (typeof this.transport.recordAuthzAudit !== "function") return;
127
+ this.transport.recordAuthzAudit(entry).catch(() => {});
128
+ }
129
+
130
+ _auditActor(authContext) {
131
+ const principal = authContext?.principal;
132
+ return {
133
+ provider: principal?.provider ?? null,
134
+ subject: principal?.subject ?? null,
135
+ display: principal?.displayName ?? principal?.email ?? null,
136
+ };
137
+ }
138
+
139
+ _shouldRecordBreakGlass(actorKey, sessionId) {
140
+ const key = `${actorKey}${sessionId}`;
141
+ const now = Date.now();
142
+ const expiry = this._breakGlassSeen.get(key);
143
+ if (expiry && expiry > now) return false;
144
+ if (this._breakGlassSeen.size > 5000) this._breakGlassSeen.clear();
145
+ this._breakGlassSeen.set(key, now + 15 * 60 * 1000);
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Gate one dispatched method. Returns { snapshot } (the access snapshot
151
+ * for session-scoped ops, so handlers can reuse it — e.g. sender
152
+ * relation) or throws 403/404. With enforcement off, would-be denials
153
+ * are audited and allowed through (dark launch).
154
+ */
155
+ async _authorizeCall(method, safeParams, authContext, { owner, isAdmin }) {
156
+ const spec = getMethodAccess(method);
157
+ const access = spec?.access || "authed";
158
+
159
+ if (access === "authed" || access === "session:create" || access === "facts:read" || access === "group:list" || access === "session:list") {
160
+ // List/read scoping happens in the case handlers (viewer-scoped
161
+ // catalog paths); creation stamps owner+visibility there too.
162
+ return { snapshot: null };
163
+ }
164
+
165
+ if (access === "fleet:read" || access === "fleet:admin") {
166
+ if (!isAdmin) {
167
+ const reason = access === "fleet:admin"
168
+ ? "This operation requires the admin role."
169
+ : "Fleet-wide observability requires the admin role.";
170
+ this._recordAudit({
171
+ actor: this._auditActor(authContext),
172
+ action: method,
173
+ decision: this.authz.enforce ? "deny" : "would_deny",
174
+ reason,
175
+ });
176
+ if (this.authz.enforce || access === "fleet:admin") {
177
+ // fleet:admin has always been enforced (op.admin) —
178
+ // keep it hard regardless of the dark-launch flag.
179
+ throw forbiddenError(reason);
180
+ }
181
+ }
182
+ return { snapshot: null };
183
+ }
184
+
185
+ if (access === "facts:write") {
186
+ await this._authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin });
187
+ return { snapshot: null };
188
+ }
189
+
190
+ if (access === "group:manage") {
191
+ await this._authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin });
192
+ return { snapshot: null };
193
+ }
194
+
195
+ if (access === "authz:audit") {
196
+ const sessionId = safeParams.sessionId ? String(safeParams.sessionId) : null;
197
+ if (isAdmin) return { snapshot: null };
198
+ if (!sessionId) throw forbiddenError("Fleet-wide audit requires the admin role. Pass sessionId to read audit for a session you own.");
199
+ // Owner-only, and hard-enforced (session:share) so a missing/deleted
200
+ // session id can't open the audit trail during dark-launch.
201
+ return this._gateSession(method, "session:share", sessionId, authContext, { owner, isAdmin });
202
+ }
203
+
204
+ if (access === "session:copy") {
205
+ const [from, to] = await Promise.all([
206
+ this._gateSession(method, "session:read", safeParams.fromSessionId, authContext, { owner, isAdmin }),
207
+ this._gateSession(method, "session:write", safeParams.toSessionId, authContext, { owner, isAdmin }),
208
+ ]);
209
+ return { snapshot: to.snapshot ?? from.snapshot };
210
+ }
211
+
212
+ if (access.startsWith("session:")) {
213
+ const sessionId = safeParams[spec.sessionParam];
214
+ return this._gateSession(method, access, sessionId, authContext, { owner, isAdmin });
215
+ }
216
+
217
+ return { snapshot: null };
218
+ }
219
+
220
+ /** Whether the deployment's transport can resolve access snapshots at all. */
221
+ _accessSnapshotSupported() {
222
+ return typeof this.transport.getSessionAccess === "function";
223
+ }
224
+
225
+ async _getAccessSnapshot(sessionId, owner) {
226
+ if (!sessionId || !this._accessSnapshotSupported()) return null;
227
+ return this.transport.getSessionAccess(String(sessionId), {
228
+ provider: owner?.provider ?? "",
229
+ subject: owner?.subject ?? "",
230
+ });
231
+ }
232
+
233
+ async _gateSession(method, accessClass, sessionId, authContext, { owner, isAdmin }) {
234
+ // session:share is a brand-new capability with no pre-model behavior
235
+ // to preserve, so it is enforced even during the ownership dark-launch
236
+ // — otherwise a user could pre-plant a durable grant that survives the
237
+ // flip to enforce (adversarial review HIGH-2).
238
+ const effectiveEnforce = this.authz.enforce || accessClass === "session:share";
239
+ const hasSessionId = sessionId != null && String(sessionId).trim() !== "";
240
+
241
+ // HIGH-1: a supplied-but-unresolvable id (missing OR soft-deleted —
242
+ // cms_get_session_access returns no row for either) must not open the
243
+ // gate. Only genuinely id-less ops get the permissive null path. Skip
244
+ // when the transport can't resolve snapshots at all (legacy/no-auth).
245
+ if (hasSessionId && this._accessSnapshotSupported()) {
246
+ const snapshot = await this._getAccessSnapshot(sessionId, owner);
247
+ if (!snapshot) {
248
+ this._recordAudit({
249
+ actor: this._auditActor(authContext),
250
+ action: method,
251
+ sessionId: String(sessionId),
252
+ decision: effectiveEnforce ? "deny" : "would_deny",
253
+ reason: "session not found or deleted",
254
+ });
255
+ if (!effectiveEnforce) return { snapshot: null };
256
+ throw notFoundError();
257
+ }
258
+ return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
259
+ }
260
+
261
+ const snapshot = await this._getAccessSnapshot(sessionId, owner);
262
+ return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
263
+ }
264
+
265
+ _decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce }) {
266
+ const decision = evaluateSessionAccess(accessClass, snapshot, {
267
+ isAdmin,
268
+ systemReadable: this.authz.systemVisibility === "read",
269
+ });
270
+
271
+ if (decision.allowed) {
272
+ if (decision.breakGlass && BREAK_GLASS_AUDITED[accessClass !== "session:read" ? "any" : method]) {
273
+ const actor = this._auditActor(authContext);
274
+ const actorKey = `${actor.provider}/${actor.subject}`;
275
+ if (this._shouldRecordBreakGlass(actorKey, String(sessionId))) {
276
+ this._recordAudit({
277
+ actor,
278
+ action: method,
279
+ sessionId: String(sessionId),
280
+ decision: "break_glass",
281
+ reason: "Admin access to a private session owned by another user",
282
+ });
283
+ }
284
+ }
285
+ return { snapshot };
286
+ }
287
+
288
+ this._recordAudit({
289
+ actor: this._auditActor(authContext),
290
+ action: method,
291
+ sessionId: sessionId ? String(sessionId) : null,
292
+ decision: effectiveEnforce ? "deny" : "would_deny",
293
+ reason: decision.notFound ? "not visible" : decision.reason,
294
+ });
295
+
296
+ if (!effectiveEnforce) return { snapshot };
297
+ throw decision.notFound ? notFoundError() : forbiddenError(decision.reason);
298
+ }
299
+
300
+ /**
301
+ * Facts write containment: non-admin callers may write/delete shared
302
+ * facts (the deployment's collaboration memory) and facts of sessions
303
+ * they can WRITE; anything else — in particular pattern deletes over
304
+ * other sessions' private facts — is denied.
305
+ */
306
+ async _authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin }) {
307
+ if (isAdmin) return;
308
+ const inputs = Array.isArray(safeParams.input) ? safeParams.input : [safeParams.input];
309
+ for (const input of inputs) {
310
+ const sessionId = typeof input?.sessionId === "string" && input.sessionId.trim() ? input.sessionId.trim() : null;
311
+ const scopeKey = typeof input?.scopeKey === "string" ? input.scopeKey : "";
312
+ const sessionScopeFromKey = scopeKey.startsWith("session:") ? scopeKey.split(":")[1] || null : null;
313
+ const targetSession = sessionId || sessionScopeFromKey;
314
+ const isSharedScope = !targetSession && (input?.shared === true || scopeKey.startsWith("shared:") || input?.scope === "shared" || (!scopeKey && !sessionId));
315
+ if (isSharedScope) continue;
316
+ await this._gateSession(method, "session:write", targetSession, authContext, { owner, isAdmin });
317
+ }
318
+ }
319
+
320
+ async _authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin }) {
321
+ if (isAdmin) return;
322
+ const groupId = safeParams.groupId ? String(safeParams.groupId) : null;
323
+ if (groupId) {
324
+ const groups = await this.transport.listSessionGroups().catch(() => []);
325
+ const group = (groups || []).find((g) => g.groupId === groupId);
326
+ if (group) {
327
+ const groupOwner = normalizeOwnerPrincipal(group.owner);
328
+ const allowed = !groupOwner || (owner && groupOwner.provider === owner.provider && groupOwner.subject === owner.subject);
329
+ if (!allowed) {
330
+ this._recordAudit({
331
+ actor: this._auditActor(authContext),
332
+ action: method,
333
+ target: `group:${groupId}`,
334
+ decision: this.authz.enforce ? "deny" : "would_deny",
335
+ reason: "group owned by another user",
336
+ });
337
+ if (this.authz.enforce) {
338
+ throw forbiddenError("Only the group owner or an admin can manage this group.");
339
+ }
340
+ }
341
+ }
342
+ }
343
+ // Assign/move (including ungroup, groupId=null) also mutates the
344
+ // sessions themselves — gate each as session:manage so a user can't
345
+ // pull another user's session out of (or into) a group
346
+ // (adversarial review MEDIUM-2).
347
+ const sessionIds = Array.isArray(safeParams.sessionIds) ? safeParams.sessionIds : [];
348
+ for (const sessionId of sessionIds) {
349
+ await this._gateSession(method, "session:manage", sessionId, authContext, { owner, isAdmin });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Placement viewer for the CMS placement procs. canRead inside the procs
355
+ * is permissive when ownership enforcement is off (admin OR NOT enforce);
356
+ * the target-group ownership check is always enforced regardless.
357
+ */
358
+ _placementViewer(authContext, isAdmin) {
359
+ return { ...placementPrincipal(authContext), isAdmin: isAdmin || !this.authz.enforce };
360
+ }
361
+
362
+ /**
363
+ * Viewer-private placement: upsert (or clear, when groupId is null) the
364
+ * caller's own group placement for each session tree root. Requires read
365
+ * access per session; the target group must be owned by the caller —
366
+ * cross-user placement is structurally impossible.
367
+ */
368
+ async _placeSessionsInGroup(method, safeParams, authContext, { isAdmin }) {
369
+ const groupId = safeParams.groupId == null ? null : String(safeParams.groupId).trim() || null;
370
+ const sessionIds = Array.isArray(safeParams.sessionIds) ? safeParams.sessionIds : [];
371
+ try {
372
+ return await this.transport.mgmt.placeSessionsInGroup(
373
+ this._placementViewer(authContext, isAdmin),
374
+ sessionIds,
375
+ groupId,
376
+ );
377
+ } catch (error) {
378
+ if (/was not found or is not owned by the caller/i.test(String(error?.message || ""))) {
379
+ this._recordAudit({
380
+ actor: this._auditActor(authContext),
381
+ action: method,
382
+ target: `group:${groupId}`,
383
+ decision: "deny",
384
+ reason: "group not found or not owned by the caller",
385
+ });
386
+ throw forbiddenError("Session group not found or not owned by you.");
387
+ }
388
+ throw error;
389
+ }
390
+ }
391
+
392
+ /** A creator-supplied groupId is an initial placement: it must be one of the caller's groups. */
393
+ async _assertPlacementGroupOwned(groupId, authContext, { isAdmin }) {
394
+ const normalized = groupId == null ? null : String(groupId).trim() || null;
395
+ if (!normalized) return;
396
+ const groups = await this.transport.mgmt.listSessionGroups(this._placementViewer(authContext, isAdmin));
397
+ if (!(groups || []).some((group) => group.groupId === normalized)) {
398
+ throw forbiddenError("Session group not found or not owned by you.");
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Guarantee a created session lands in the requested group under the
404
+ * placement principal, not the session owner. CMS places in-transaction
405
+ * only when an owner principal reaches it, so no-auth deployments (owner
406
+ * null, placement viewer anonymous) would otherwise drop the group
407
+ * silently. The upsert is idempotent, so the authenticated path (already
408
+ * placed) is skipped via the viewerGroupId check; ownership was verified
409
+ * before create, so a placement failure is unexpected and best-effort.
410
+ */
411
+ async _ensureCreatedPlacement(view, groupId, authContext, isAdmin) {
412
+ const normalized = groupId == null ? null : String(groupId).trim() || null;
413
+ if (!normalized || !view?.sessionId || view.viewerGroupId === normalized) return view;
414
+ try {
415
+ await this.transport.mgmt.placeSessionsInGroup(
416
+ this._placementViewer(authContext, isAdmin),
417
+ [view.sessionId],
418
+ normalized,
419
+ );
420
+ return { ...view, viewerGroupId: normalized };
421
+ } catch {
422
+ return view;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Viewer descriptor for viewer-scoped listing, or null for unfiltered.
428
+ * A non-admin without a resolvable identity in enforce mode gets a viewer
429
+ * that matches no owner and no targeted share, so they see only
430
+ * deployment-shared trees (shared_read/shared_write are visible to every
431
+ * admitted user by design) — never the unfiltered fleet or another user's
432
+ * private sessions (adversarial review LOW-1 / NEW-5).
433
+ */
434
+ _listViewer(owner, isAdmin) {
435
+ if (isAdmin || !this.authz.enforce) return null;
436
+ if (!owner) return { provider: "nomatch", subject: "nomatch", systemVisible: false };
437
+ return {
438
+ provider: owner.provider,
439
+ subject: owner.subject,
440
+ systemVisible: this.authz.systemVisibility === "read",
441
+ };
91
442
  }
92
443
 
93
444
  async start() {
@@ -116,26 +467,14 @@ export class PortalRuntime {
116
467
  }
117
468
 
118
469
  async resolveSessionGroupOwner(input = {}, authOwner = null) {
470
+ // Groups belong to the authenticated creator. Ownership is never
471
+ // inferred from selected sessions, and never null: no-auth
472
+ // deployments use the same anonymous principal as placement so
473
+ // every group can receive placements.
474
+ if (authOwner) return authOwner;
119
475
  const inputOwner = normalizeOwnerPrincipal(input?.owner);
120
476
  if (inputOwner) return inputOwner;
121
-
122
- const sessionIds = Array.isArray(input?.sessionIds)
123
- ? Array.from(new Set(input.sessionIds.map((id) => String(id || "").trim()).filter(Boolean)))
124
- : [];
125
- if (sessionIds.length > 0 && typeof this.transport.getSession === "function") {
126
- const owners = [];
127
- for (const sessionId of sessionIds) {
128
- const session = await this.transport.getSession(sessionId).catch(() => null);
129
- if (!session || session.isSystem || session.isGroup || session.parentSessionId) continue;
130
- owners.push(normalizeOwnerPrincipal(session.owner));
131
- }
132
- const ownerKeys = new Set(owners.map((owner) => ownerKey(owner) || ""));
133
- if (owners.length > 0 && ownerKeys.size === 1) {
134
- return owners[0] ?? null;
135
- }
136
- }
137
-
138
- return authOwner;
477
+ return { provider: "anonymous", subject: "anonymous" };
139
478
  }
140
479
 
141
480
  async getBootstrap() {
@@ -160,6 +499,14 @@ export class PortalRuntime {
160
499
  sessionCreationPolicy: typeof this.transport.getSessionCreationPolicy === "function"
161
500
  ? this.transport.getSessionCreationPolicy()
162
501
  : null,
502
+ // Ownership/visibility posture (security model) so clients (portal,
503
+ // MCP, TUI) can explain why a session isn't listed or a send was
504
+ // refused, and default the share UI correctly.
505
+ authz: {
506
+ ownershipEnforced: this.authz.enforce,
507
+ defaultVisibility: this.authz.defaultVisibility,
508
+ systemVisibility: this.authz.systemVisibility,
509
+ },
163
510
  };
164
511
  }
165
512
 
@@ -172,11 +519,19 @@ export class PortalRuntime {
172
519
  // visibility so a plain caller cannot read another session's private facts.
173
520
  const role = authContext?.authorization?.role;
174
521
  const isAdmin = role === "admin" || role === "anonymous";
522
+ // Ownership/visibility gate — the single enforcement point for both
523
+ // the generated /api/v1 routes and the legacy /api/rpc dispatcher.
524
+ const gate = await this._authorizeCall(method, safeParams, authContext, { owner, isAdmin });
525
+ const listViewer = this._listViewer(owner, isAdmin);
175
526
  switch (method) {
176
527
  case "listSessions":
177
- return this.transport.listSessions();
528
+ return listViewer
529
+ ? this.transport.mgmt.listSessionsVisible(listViewer, placementPrincipal(authContext))
530
+ : this.transport.mgmt.listSessions(placementPrincipal(authContext));
178
531
  case "listSessionGroups":
179
- return this.transport.listSessionGroups();
532
+ // Viewer-scoped: everyone (admins included) sees only their
533
+ // own groups — a group is a user's private organization.
534
+ return this.transport.mgmt.listSessionGroups(this._placementViewer(authContext, isAdmin));
180
535
  case "createSessionGroup":
181
536
  return this.transport.createSessionGroup({
182
537
  ...(safeParams.input || {}),
@@ -184,18 +539,22 @@ export class PortalRuntime {
184
539
  });
185
540
  case "updateSessionGroup":
186
541
  return this.transport.updateSessionGroup(safeParams.groupId, safeParams.patch || {});
542
+ case "placeSessionsInGroup":
187
543
  case "assignSessionsToGroup":
188
- return this.transport.assignSessionsToGroup(safeParams.groupId, safeParams.sessionIds || []);
189
544
  case "moveSessionsToGroup":
190
- return this.transport.moveSessionsToGroup(safeParams.groupId ?? null, safeParams.sessionIds || []);
545
+ return this._placeSessionsInGroup(method, safeParams, authContext, { isAdmin });
191
546
  case "getChildOutcome":
192
547
  return this.transport.getChildOutcome(safeParams.childSessionId);
193
548
  case "listChildOutcomes":
194
549
  return this.transport.listChildOutcomes(safeParams.parentSessionId);
195
550
  case "listSessionsPage":
196
- return this.transport.listSessionsPage(normalizeSessionPageOptions(safeParams));
551
+ return this.transport.mgmt.listSessionsPage({
552
+ ...normalizeSessionPageOptions(safeParams),
553
+ ...(listViewer ? { viewer: listViewer } : {}),
554
+ placement: placementPrincipal(authContext),
555
+ });
197
556
  case "getSession":
198
- return this.transport.getSession(safeParams.sessionId);
557
+ return this.transport.mgmt.getSession(safeParams.sessionId, placementPrincipal(authContext));
199
558
  case "getOrchestrationStats":
200
559
  return this.transport.getOrchestrationStats(safeParams.sessionId);
201
560
  case "getSessionMetricSummary":
@@ -357,16 +716,21 @@ export class PortalRuntime {
357
716
  return this.transport.pruneDeletedSummaries(new Date(safeParams.olderThan));
358
717
  case "getExecutionHistory":
359
718
  return this.transport.getExecutionHistory(safeParams.sessionId, safeParams.executionId);
360
- case "createSession":
361
- return this.transport.createSession({
719
+ case "createSession": {
720
+ await this._assertPlacementGroupOwned(safeParams.groupId, authContext, { isAdmin });
721
+ const created = await this.transport.createSession({
362
722
  model: safeParams.model,
363
723
  reasoningEffort: safeParams.reasoningEffort,
364
724
  contextTier: safeParams.contextTier,
365
725
  groupId: safeParams.groupId,
366
726
  owner,
727
+ visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
367
728
  });
368
- case "createSessionForAgent":
369
- return this.transport.createSessionForAgent(safeParams.agentName, {
729
+ return this._ensureCreatedPlacement(created, safeParams.groupId, authContext, isAdmin);
730
+ }
731
+ case "createSessionForAgent": {
732
+ await this._assertPlacementGroupOwned(safeParams.groupId, authContext, { isAdmin });
733
+ const created = await this.transport.createSessionForAgent(safeParams.agentName, {
370
734
  model: safeParams.model,
371
735
  reasoningEffort: safeParams.reasoningEffort,
372
736
  contextTier: safeParams.contextTier,
@@ -376,17 +740,113 @@ export class PortalRuntime {
376
740
  initialPrompt: safeParams.initialPrompt,
377
741
  groupId: safeParams.groupId,
378
742
  owner,
743
+ visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
379
744
  });
745
+ return this._ensureCreatedPlacement(created, safeParams.groupId, authContext, isAdmin);
746
+ }
380
747
  case "listCreatableAgents":
381
748
  return this.transport.listCreatableAgents();
382
749
  case "getSessionCreationPolicy":
383
750
  return this.transport.getSessionCreationPolicy();
384
751
  case "sendMessage":
385
- return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, safeParams.options);
752
+ return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, {
753
+ ...(safeParams.options && typeof safeParams.options === "object" ? safeParams.options : {}),
754
+ // Server-stamped; a client-supplied options.sender is overwritten.
755
+ sender: this._buildSender(authContext, gate.snapshot, { isAdmin, origin: safeParams.options?.origin }),
756
+ });
386
757
  case "sendAnswer":
387
- return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer);
758
+ return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer, {
759
+ sender: this._buildSender(authContext, gate.snapshot, { isAdmin }),
760
+ });
388
761
  case "sendSessionEvent":
389
762
  return this.transport.sendSessionEvent(safeParams.sessionId, safeParams.eventName, safeParams.data);
763
+
764
+ // ── Session sharing (security model) ────────────────────────
765
+ case "getSessionAccess": {
766
+ const snapshot = gate.snapshot ?? await this._getAccessSnapshot(safeParams.sessionId, owner);
767
+ if (!snapshot) {
768
+ throw notFoundError();
769
+ }
770
+ const relation = snapshot.viewerIsOwner ? "owner" : (isAdmin ? "admin" : (snapshot.viewerShareAccess ? "collaborator" : "none"));
771
+ const canWrite = isAdmin || snapshot.viewerIsOwner || snapshot.visibility === "shared_write" || snapshot.viewerShareAccess === "write";
772
+ const canManage = isAdmin || snapshot.viewerIsOwner;
773
+ // The caller's private placement of this tree (placements
774
+ // live on the root), never another viewer's.
775
+ const rootView = snapshot.rootSessionId
776
+ ? await this.transport.mgmt.getSession(snapshot.rootSessionId, placementPrincipal(authContext)).catch(() => null)
777
+ : null;
778
+ return {
779
+ sessionId: safeParams.sessionId,
780
+ rootSessionId: snapshot.rootSessionId,
781
+ isSystem: snapshot.isSystem,
782
+ visibility: snapshot.visibility,
783
+ owner: snapshot.owner,
784
+ relation,
785
+ canWrite: snapshot.isSystem ? isAdmin : canWrite,
786
+ canManage: snapshot.isSystem ? isAdmin : canManage,
787
+ viewerGroupId: rootView?.viewerGroupId ?? null,
788
+ enforced: this.authz.enforce,
789
+ };
790
+ }
791
+ case "setSessionVisibility": {
792
+ const visibility = normalizeVisibility(safeParams.visibility, null);
793
+ if (!visibility) {
794
+ throw Object.assign(new Error("visibility must be private | shared_read | shared_write"), { code: "INVALID_REQUEST" });
795
+ }
796
+ await this.transport.setSessionVisibility(safeParams.sessionId, visibility);
797
+ this._recordAudit({
798
+ actor: this._auditActor(authContext),
799
+ action: "setSessionVisibility",
800
+ sessionId: String(safeParams.sessionId),
801
+ decision: "share_change",
802
+ reason: `visibility=${visibility}`,
803
+ });
804
+ return { sessionId: safeParams.sessionId, visibility };
805
+ }
806
+ case "grantSessionShare": {
807
+ const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
808
+ const access = safeParams.access === "write" ? "write" : safeParams.access === "read" ? "read" : null;
809
+ if (!grantee.provider || !grantee.subject || !access) {
810
+ throw Object.assign(new Error("grantSessionShare requires user { provider, subject } and access read|write"), { code: "INVALID_REQUEST" });
811
+ }
812
+ await this.transport.grantSessionShare(safeParams.sessionId, grantee, access, owner);
813
+ this._recordAudit({
814
+ actor: this._auditActor(authContext),
815
+ action: "grantSessionShare",
816
+ sessionId: String(safeParams.sessionId),
817
+ target: `${grantee.provider}/${grantee.subject}`,
818
+ decision: "share_change",
819
+ reason: `access=${access}`,
820
+ });
821
+ return { sessionId: safeParams.sessionId, granted: { ...grantee, access } };
822
+ }
823
+ case "revokeSessionShare": {
824
+ const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
825
+ if (!grantee.provider || !grantee.subject) {
826
+ throw Object.assign(new Error("revokeSessionShare requires user { provider, subject }"), { code: "INVALID_REQUEST" });
827
+ }
828
+ await this.transport.revokeSessionShare(safeParams.sessionId, grantee);
829
+ this._recordAudit({
830
+ actor: this._auditActor(authContext),
831
+ action: "revokeSessionShare",
832
+ sessionId: String(safeParams.sessionId),
833
+ target: `${grantee.provider}/${grantee.subject}`,
834
+ decision: "share_change",
835
+ reason: "revoked",
836
+ });
837
+ return { sessionId: safeParams.sessionId, revoked: grantee };
838
+ }
839
+ case "listSessionShares":
840
+ return this.transport.listSessionShares(safeParams.sessionId);
841
+ case "listKnownUsers":
842
+ return typeof this.transport.listKnownUsers === "function"
843
+ ? this.transport.listKnownUsers({ limit: safeParams.limit })
844
+ : [];
845
+ case "listAuthzAudit":
846
+ return this.transport.listAuthzAudit({
847
+ limit: safeParams.limit,
848
+ sessionId: safeParams.sessionId ?? null,
849
+ });
390
850
  case "getSessionStatus":
391
851
  return this.transport.getSessionStatus(safeParams.sessionId);
392
852
  case "waitForStatusChange": {
@@ -481,19 +941,42 @@ export class PortalRuntime {
481
941
  }
482
942
  }
483
943
 
484
- async downloadArtifact(sessionId, filename) {
944
+ /**
945
+ * Server-stamped message sender: identity from the validated auth
946
+ * context, relation from the access snapshot. Never trusts
947
+ * client-supplied identity fields; `origin` is client-declared display
948
+ * metadata only.
949
+ */
950
+ _buildSender(authContext, snapshot, { isAdmin = false, origin } = {}) {
951
+ const principal = normalizeSessionOwner(authContext);
952
+ if (!principal) return undefined;
953
+ const allowedOrigins = new Set(["portal", "tui", "mcp", "api"]);
954
+ return {
955
+ kind: "user",
956
+ provider: principal.provider,
957
+ subject: principal.subject,
958
+ display: principal.displayName || principal.email || principal.subject,
959
+ relation: relationFor(snapshot, { isAdmin }),
960
+ origin: allowedOrigins.has(origin) ? origin : "api",
961
+ };
962
+ }
963
+
964
+ async downloadArtifact(sessionId, filename, authContext = null) {
485
965
  await this.start();
966
+ await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
486
967
  return this.transport.downloadArtifact(sessionId, filename);
487
968
  }
488
969
 
489
- async getArtifactMetadata(sessionId, filename) {
970
+ async getArtifactMetadata(sessionId, filename, authContext = null) {
490
971
  await this.start();
972
+ await this._gateBespokeRead("getArtifactMetadata", sessionId, authContext);
491
973
  if (typeof this.transport.getArtifactMetadata !== "function") return null;
492
974
  return this.transport.getArtifactMetadata(sessionId, filename);
493
975
  }
494
976
 
495
- async downloadArtifactBinary(sessionId, filename) {
977
+ async downloadArtifactBinary(sessionId, filename, authContext = null) {
496
978
  await this.start();
979
+ await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
497
980
  if (typeof this.transport.downloadArtifactBinary === "function") {
498
981
  return this.transport.downloadArtifactBinary(sessionId, filename);
499
982
  }
@@ -509,6 +992,39 @@ export class PortalRuntime {
509
992
  };
510
993
  }
511
994
 
995
+ /** session:read gate for the bespoke (non-dispatched) artifact routes. */
996
+ async _gateBespokeRead(action, sessionId, authContext) {
997
+ const role = authContext?.authorization?.role;
998
+ const isAdmin = role === "admin" || role === "anonymous";
999
+ const owner = normalizeSessionOwner(authContext);
1000
+ await this._gateSession(action, "session:read", sessionId, authContext, { owner, isAdmin });
1001
+ }
1002
+
1003
+ /**
1004
+ * WebSocket subscription gate (api/ws.js). Throws 403/404 when the
1005
+ * caller cannot read the session; audited like every other decision.
1006
+ */
1007
+ async authorizeSessionSubscribe(sessionId, authContext) {
1008
+ await this.start();
1009
+ await this._gateBespokeRead("subscribeSession", sessionId, authContext);
1010
+ }
1011
+
1012
+ /** Log tail is fleet-wide observability: admin (or dark-launch). */
1013
+ async authorizeLogSubscribe(authContext) {
1014
+ const role = authContext?.authorization?.role;
1015
+ const isAdmin = role === "admin" || role === "anonymous";
1016
+ if (isAdmin) return;
1017
+ this._recordAudit({
1018
+ actor: this._auditActor(authContext),
1019
+ action: "subscribeLogs",
1020
+ decision: this.authz.enforce ? "deny" : "would_deny",
1021
+ reason: "log tail requires the admin role",
1022
+ });
1023
+ if (this.authz.enforce) {
1024
+ throw forbiddenError("The live log tail requires the admin role.");
1025
+ }
1026
+ }
1027
+
512
1028
  subscribeSession(sessionId, handler) {
513
1029
  return this.transport.subscribeSession(sessionId, handler);
514
1030
  }