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.
- package/README.md +6 -0
- package/mcp/README.md +12 -0
- package/mcp/dist/src/context.d.ts +13 -0
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +20 -0
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/tools/capabilities.d.ts +3 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +8 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +114 -0
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +3 -2
- package/tui/src/app.js +19 -2
- package/tui/src/auth/cli.js +13 -0
- package/tui/src/node-sdk-transport.js +53 -8
- package/tui/tui-splash-mobile.txt +5 -7
- package/tui/tui-splash.txt +13 -9
- package/ui/core/src/commands.js +2 -0
- package/ui/core/src/controller.js +275 -6
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +4 -0
- package/ui/core/src/selectors.js +139 -14
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +555 -117
- package/web/api/router.js +7 -6
- package/web/api/ws.js +9 -0
- package/web/auth/index.js +5 -0
- package/web/auth/providers/dev.js +119 -0
- package/web/authz.js +142 -0
- package/web/dist/assets/index-BnxC8cNG.js +24 -0
- package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
- package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
- package/web/dist/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +453 -9
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/web/dist/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/assets/pilotswarm-
|
|
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-
|
|
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.
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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;
|