@opengeni/core 2.7.5-canary.6 → 2.8.0-canary.0

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.
@@ -1,3 +1,4 @@
1
+ import { type ConnectionModelRestrictions } from "@opengeni/db";
1
2
  import { type ConfiguredModel, type Settings } from "@opengeni/config";
2
3
  import { type ModelAvailabilityV1, type ModelCredentialReadinessV1, type WorkspaceModelPolicyContract } from "@opengeni/contracts";
3
4
  import { type Database } from "@opengeni/db";
@@ -62,6 +63,7 @@ export type ModelCredentialReadinessObservation = {
62
63
  };
63
64
  export declare const MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS: number;
64
65
  export type WorkspaceModelSelectionInput = {
66
+ connectionModelRestrictions?: ConnectionModelRestrictions;
65
67
  settings: Settings;
66
68
  policy: WorkspaceModelPolicyContract | null;
67
69
  codexSubscriptionActive: boolean;
@@ -1,4 +1,4 @@
1
- import { SessionAuthorizationActor, SessionAuthorizationListScope, type AccessGrant, type SessionAuthorizationOperation, type SessionAuthorizationSurface, type SessionAuthorizationTarget } from "@opengeni/contracts";
1
+ import { SessionAuthorizationActor, SessionAuthorizationListScope, type AccessGrant, type SessionAgentAccess, type SessionAgentAccessViewer, type SessionAuthorizationOperation, type SessionAuthorizationSurface, type SessionAuthorizationTarget, type SessionEndUser } from "@opengeni/contracts";
2
2
  import { type Database, type SessionRlsActorContext } from "@opengeni/db";
3
3
  import type { AppDependencies } from "./dependencies.js";
4
4
  export type SessionAuthorizationDependencies = Pick<AppDependencies, "db" | "sessionAuthorization">;
@@ -19,6 +19,34 @@ export type ResolvedSessionAuthorization = {
19
19
  relatedSessionAccess: "target" | "root";
20
20
  reauthorizeAfterMs: number | null;
21
21
  };
22
+ /** The frozen agent-access facts of one session, as the pairwise rule sees them. */
23
+ export type SessionAgentAccessFacts = {
24
+ agentAccess: SessionAgentAccess;
25
+ endUser: SessionEndUser | null;
26
+ };
27
+ /**
28
+ * The agent-to-agent reach rule (migration 0427) for one caller/target pair
29
+ * that live in DIFFERENT root trees. A caller always keeps its own tree, so
30
+ * this is never consulted for same-root access. The most restrictive side
31
+ * wins: a `session` side denies everything; a `user` side requires both
32
+ * sessions to carry the same non-null end-user label; two `workspace` sides
33
+ * are today's behaviour. Humans and API keys never pass through here.
34
+ */
35
+ export declare function agentAccessPermitsCrossTreeAccess(caller: SessionAgentAccessFacts, target: SessionAgentAccessFacts): boolean;
36
+ /**
37
+ * The in-database list scope for one calling attempt. A `session` caller is
38
+ * pinned to its own root tree when the host does not narrow it. Every caller
39
+ * carries the viewer so `sessionAuthorizationScopeFilter` intersects the host
40
+ * scope with the same pairwise rule in SQL, including for session callers.
41
+ */
42
+ export declare function agentAccessListScopeForViewer(viewer: SessionAgentAccessViewer, hostScope?: SessionAuthorizationListScope | null): SessionAuthorizationListScope;
43
+ /**
44
+ * Whether a grant acts with live agent-attempt authority: an explicit
45
+ * `agent_attempt` principal kind, or (for legacy tokens without one) any
46
+ * worker-signed exact attempt claim. Shared by the session seam and by the
47
+ * narrowing fences that let a human widen a session an agent may only narrow.
48
+ */
49
+ export declare function grantHasAgentAttemptAuthority(grant: AccessGrant): boolean;
22
50
  export declare function sessionRlsActorForAuthorization(authorization: ResolvedSessionAuthorization): SessionRlsActorContext;
23
51
  export declare function withResolvedSessionAuthorization<T>(authorization: ResolvedSessionAuthorization, fn: () => Promise<T>): Promise<T>;
24
52
  /**
@@ -0,0 +1,7 @@
1
+ export type SiteSessionOrigin = {
2
+ siteId: string;
3
+ title: string;
4
+ };
5
+ /** Request-local provenance, set only after the API validates a published Site version. */
6
+ export declare function withSiteSessionOrigin<T>(origin: SiteSessionOrigin, run: () => T): T;
7
+ export declare function sessionCreationMetadata(metadata: Record<string, unknown>): Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "2.7.5-canary.6",
3
+ "version": "2.8.0-canary.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,17 +54,17 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.29.0",
57
- "@opengeni/capabilities": "^0.3.2-canary.7",
58
- "@opengeni/codex": "^0.2.21-canary.6",
59
- "@opengeni/config": "^1.0.0-canary.6",
60
- "@opengeni/contracts": "^2.13.0-canary.7",
61
- "@opengeni/db": "^4.0.0-canary.6",
62
- "@opengeni/documents": "^0.8.19-canary.6",
63
- "@opengeni/events": "^0.4.17-canary.6",
64
- "@opengeni/network": "^0.3.0-canary.7",
65
- "@opengeni/observability": "^0.8.19-canary.6",
66
- "@opengeni/runtime": "^2.3.0-canary.6",
67
- "@opengeni/storage": "^0.2.120-canary.6",
57
+ "@opengeni/capabilities": "^0.3.3-canary.0",
58
+ "@opengeni/codex": "^0.2.22-canary.0",
59
+ "@opengeni/config": "^1.0.1-canary.0",
60
+ "@opengeni/contracts": "^2.14.0-canary.0",
61
+ "@opengeni/db": "^4.1.0-canary.0",
62
+ "@opengeni/documents": "^0.8.20-canary.0",
63
+ "@opengeni/events": "^0.4.18-canary.0",
64
+ "@opengeni/network": "^0.3.1-canary.0",
65
+ "@opengeni/observability": "^0.8.20-canary.0",
66
+ "@opengeni/runtime": "^2.4.0-canary.0",
67
+ "@opengeni/storage": "^0.2.121-canary.0",
68
68
  "hono": "^4.12.18",
69
69
  "zod": "^4.2.1"
70
70
  },
@@ -100,7 +100,7 @@ export function withWorkspaceDefaultMcpTools(
100
100
  runtimeSettings: McpSettings,
101
101
  defaults: WorkspaceSessionToolDefaults | null,
102
102
  ): ToolRef[] {
103
- if (!defaults) {
103
+ if (!defaults?.mcpServerIds) {
104
104
  return withDefaultEnabledCapabilityMcpTools(tools, settings, runtimeSettings);
105
105
  }
106
106
  return mergeToolRefs(
@@ -1,4 +1,8 @@
1
- import { resolveFirstPartyMcpToolPolicy, type Settings } from "@opengeni/config";
1
+ import {
2
+ allowedFirstPartyMcpToolsForSession,
3
+ resolveFirstPartyMcpToolPolicy,
4
+ type Settings,
5
+ } from "@opengeni/config";
2
6
  import type {
3
7
  AccessGrant,
4
8
  McpPersonalConnectionDelegation,
@@ -17,6 +21,9 @@ import {
17
21
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
18
22
  OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
19
23
  resolveWorkspaceSessionToolDefaults,
24
+ SessionAgentAccess,
25
+ SessionEndUser,
26
+ SessionMemoryScope,
20
27
  } from "@opengeni/contracts";
21
28
  import {
22
29
  createScheduledTask,
@@ -41,6 +48,7 @@ import {
41
48
  withWorkspaceSubjectRls,
42
49
  resolveXaiProviderAccountAuthoritySnapshotForAcceptance,
43
50
  type Database,
51
+ type ScheduledTaskCreatorPolicy,
44
52
  type TemporalScheduleCleanupClaim,
45
53
  type UpdateScheduledTaskInput,
46
54
  } from "@opengeni/db";
@@ -246,6 +254,14 @@ export async function createValidatedScheduledTask(input: {
246
254
  ...scheduledConnectionSurfaceEligibility(runtimeSettings, target),
247
255
  });
248
256
  const creationInitiator = creationInitiatorForGrant(input.grant);
257
+ const creatorPolicy = creationInitiator.actor
258
+ ? await frozenScheduledTaskCreatorPolicy({
259
+ db: input.db,
260
+ settings: input.settings,
261
+ grant: input.grant,
262
+ sessionId: creationInitiator.actor.sessionId,
263
+ })
264
+ : null;
249
265
  const xaiProviderAccountAuthoritySnapshot: XaiProviderAccountAuthoritySnapshotV1 =
250
266
  creationInitiator.actor
251
267
  ? await getSessionTurnXaiProviderAccountAuthoritySnapshot(
@@ -285,6 +301,7 @@ export async function createValidatedScheduledTask(input: {
285
301
  createdByActor: creationInitiator.actor ?? null,
286
302
  personalConnectionDelegations,
287
303
  xaiProviderAccountAuthoritySnapshot,
304
+ creatorPolicy,
288
305
  targetSessionId: target?.id ?? null,
289
306
  variableSetId: input.payload.variableSetId ?? null,
290
307
  rigId: input.payload.rigId ?? null,
@@ -294,6 +311,59 @@ export async function createValidatedScheduledTask(input: {
294
311
  );
295
312
  }
296
313
 
314
+ /**
315
+ * Freeze the creating session's boundary onto an agent-created task so the
316
+ * sessions generated for it inherit exactly what the creator could see and
317
+ * do, never the deployment default. Tools are the session's effective
318
+ * model-visible selection under the deployment ceiling; permissions are the
319
+ * session's effective first-party set intersected with what the calling
320
+ * grant actually holds (a narrowly delegated spawn token cannot hand a
321
+ * schedule more than itself). The session access policy is copied from the
322
+ * projection when it exposes those facts; each absent fact is stored as null
323
+ * so a generated session keeps its own default for that key.
324
+ */
325
+ async function frozenScheduledTaskCreatorPolicy(input: {
326
+ db: Database;
327
+ settings: Settings;
328
+ grant: AccessGrant;
329
+ sessionId: string;
330
+ }): Promise<ScheduledTaskCreatorPolicy> {
331
+ const session = await getSession(input.db, input.grant.workspaceId, input.sessionId);
332
+ if (!session) {
333
+ throw new HTTPException(403, {
334
+ message: "the calling agent session is not available in this workspace",
335
+ });
336
+ }
337
+ const firstPartyMcpTools = allowedFirstPartyMcpToolsForSession(
338
+ input.settings,
339
+ session.firstPartyMcpTools,
340
+ );
341
+ const firstPartyMcpPermissions = (
342
+ session.firstPartyMcpPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS]
343
+ ).filter((permission) => hasPermission(input.grant.permissions, permission));
344
+ if (firstPartyMcpPermissions.length === 0) {
345
+ throw new HTTPException(403, {
346
+ message:
347
+ "the calling agent session holds no first-party MCP permission it could delegate to scheduled runs",
348
+ });
349
+ }
350
+ // The projection facts are validated through the access-scope contract so
351
+ // only a well-formed value is frozen; anything else stores null for that key.
352
+ const projection = session as unknown as Record<string, unknown>;
353
+ const agentAccess = SessionAgentAccess.safeParse(projection["agentAccess"]);
354
+ const endUser = SessionEndUser.safeParse(projection["endUser"]);
355
+ const memoryScope = SessionMemoryScope.safeParse(projection["memoryScope"]);
356
+ return {
357
+ firstPartyMcpTools,
358
+ firstPartyMcpPermissions,
359
+ sessionPolicy: {
360
+ agentAccess: agentAccess.success ? agentAccess.data : null,
361
+ endUser: endUser.success ? { source: endUser.data.source, id: endUser.data.id } : null,
362
+ memoryScope: memoryScope.success ? memoryScope.data : null,
363
+ },
364
+ };
365
+ }
366
+
297
367
  function nestedPostgresMessage(error: unknown): string | null {
298
368
  const queue: unknown[] = [error];
299
369
  const seen = new Set<unknown>();
@@ -188,7 +188,7 @@ export async function workspaceSessionToolPolicyDefaultServerIds(
188
188
  const availableDefaults = defaultSessionMcpServerIds(runtimeSettings.mcpServers);
189
189
  const workspace = await requireWorkspace(db, workspaceId);
190
190
  const configured = resolveWorkspaceSessionToolDefaults(workspace.settings);
191
- if (!configured) return availableDefaults;
191
+ if (!configured?.mcpServerIds) return availableDefaults;
192
192
  const available = new Set(availableDefaults);
193
193
  return sortedIds(configured.mcpServerIds.filter((id) => available.has(id)));
194
194
  }
@@ -1,4 +1,5 @@
1
1
  import { CODEX_MODEL_ID_PREFIX, isCodexBilledModel } from "@opengeni/codex";
2
+ import { sessionCreationMetadata } from "../site-session-origin";
2
3
  import {
3
4
  canonicalizeConfiguredModelId,
4
5
  configuredAllowedModels,
@@ -42,7 +43,10 @@ import {
42
43
  type ReasoningEffort,
43
44
  type ResourceRef,
44
45
  type Session,
46
+ type SessionAgentAccess,
45
47
  type SessionCommandReceipt,
48
+ type SessionEndUser,
49
+ type SessionMemoryScope,
46
50
  type SessionSkill,
47
51
  type SessionEvent,
48
52
  SessionMcpApprovalPolicy,
@@ -135,6 +139,7 @@ import type {
135
139
  SessionWorkflowClient,
136
140
  } from "../dependencies";
137
141
  import {
142
+ grantHasAgentAttemptAuthority,
138
143
  requireLiveAgentAttemptAuthorization,
139
144
  requireSessionAuthorization,
140
145
  SessionAuthorizationDeniedError,
@@ -237,11 +242,34 @@ export class SessionSpawnDeniedError extends Error {
237
242
  }
238
243
  }
239
244
 
245
+ /**
246
+ * A session's effective first-party selection as a ceiling for narrowing:
247
+ * the stored selection (or the deployment default for a legacy null) under
248
+ * the deployment ceiling, with the resume counterpart of pause authority the
249
+ * runtime already grants to existing sessions.
250
+ */
251
+ export function effectiveFirstPartyMcpToolCeiling(
252
+ stored: readonly FirstPartyMcpToolName[] | null | undefined,
253
+ policy: {
254
+ default: readonly FirstPartyMcpToolName[];
255
+ allowed: readonly FirstPartyMcpToolName[];
256
+ },
257
+ ): Set<FirstPartyMcpToolName> {
258
+ const allowed = new Set(policy.allowed);
259
+ const ceiling = new Set([...(stored ?? policy.default)].filter((tool) => allowed.has(tool)));
260
+ if (ceiling.has("goal_pause") && allowed.has("goal_resume")) ceiling.add("goal_resume");
261
+ return ceiling;
262
+ }
263
+
240
264
  /**
241
265
  * Resolve per-session first-party tool visibility without consulting
242
266
  * authorization. Top-level omission snapshots the complete runtime default;
243
267
  * child omission snapshots the parent's exact effective selection. Explicit
244
- * [] is authoritative and must never widen.
268
+ * [] is authoritative and must never widen. An explicit child selection may
269
+ * only narrow the parent's effective selection: a session that was handed a
270
+ * reduced catalog cannot spawn a child that sees more than it does. A
271
+ * top-level explicit selection keeps only the deployment ceiling (checked by
272
+ * the caller) because there is no creator selection to narrow.
245
273
  */
246
274
  export function resolveFirstPartyMcpToolsForCreate(
247
275
  requested: FirstPartyMcpToolName[] | undefined,
@@ -254,7 +282,18 @@ export function resolveFirstPartyMcpToolsForCreate(
254
282
  allowed: FIRST_PARTY_MCP_TOOL_NAMES,
255
283
  },
256
284
  ): FirstPartyMcpToolName[] {
257
- if (requested !== undefined) return [...requested];
285
+ if (requested !== undefined) {
286
+ if (parentStored !== undefined) {
287
+ const parentCeiling = effectiveFirstPartyMcpToolCeiling(parentStored, policy);
288
+ const widened = requested.find((tool) => !parentCeiling.has(tool));
289
+ if (widened) {
290
+ throw new HTTPException(403, {
291
+ message: `child first-party MCP tools may only narrow the parent session selection: ${widened}`,
292
+ });
293
+ }
294
+ }
295
+ return [...requested];
296
+ }
258
297
  const allowed = new Set(policy.allowed);
259
298
  const inherited = parentStored === undefined ? policy.default : (parentStored ?? policy.default);
260
299
  return [...inherited].filter((tool) => allowed.has(tool));
@@ -756,6 +795,12 @@ export async function createAndStartSessionWithOutcome(input: {
756
795
  // Model-visible first-party tool names. Authorization remains controlled by
757
796
  // firstPartyMcpPermissions and the target resource checks.
758
797
  firstPartyMcpTools: FirstPartyMcpToolName[];
798
+ // Agent-access scope, opaque end-user label, and typed Memory selector
799
+ // (migration 0427), already resolved against the parent by the caller.
800
+ // Omitted keeps the workspace defaults for internal lifecycle callers.
801
+ agentAccess?: SessionAgentAccess;
802
+ endUser?: SessionEndUser | null;
803
+ memoryScope?: SessionMemoryScope;
759
804
  // Encrypted DB rows plus matching safe metadata for create-time per-session
760
805
  // MCP servers. Metadata is the only shape emitted in events/responses.
761
806
  mcpServers?: CreateSessionMcpServerInput[];
@@ -969,6 +1014,9 @@ export async function createAndStartSessionWithOutcome(input: {
969
1014
  firstPartyMcpTools: input.firstPartyMcpTools,
970
1015
  instructions: input.instructions ?? null,
971
1016
  policyRole: input.policyRole ?? null,
1017
+ ...(input.agentAccess ? { agentAccess: input.agentAccess } : {}),
1018
+ ...(input.endUser !== undefined ? { endUser: input.endUser } : {}),
1019
+ ...(input.memoryScope ? { memoryScope: input.memoryScope } : {}),
972
1020
  parentSessionId: input.parentSessionId ?? null,
973
1021
  createIdempotencyKey: input.createIdempotencyKey,
974
1022
  selectedInstalledSkillIds: input.selectedInstalledSkillIds ?? [],
@@ -1059,6 +1107,9 @@ export async function createAndStartSessionWithOutcome(input: {
1059
1107
  firstPartyMcpTools: input.firstPartyMcpTools,
1060
1108
  instructions: input.instructions ?? null,
1061
1109
  policyRole: input.policyRole ?? null,
1110
+ ...(input.agentAccess ? { agentAccess: input.agentAccess } : {}),
1111
+ ...(input.endUser !== undefined ? { endUser: input.endUser } : {}),
1112
+ ...(input.memoryScope ? { memoryScope: input.memoryScope } : {}),
1062
1113
  parentSessionId: input.parentSessionId ?? null,
1063
1114
  sandboxGroupId: input.sandboxGroupId ?? null,
1064
1115
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
@@ -1582,7 +1633,10 @@ export async function postUserMessageTurn(
1582
1633
  // model inherits the session's model downstream (always a configured id).
1583
1634
  assertConfiguredModel(settings, requestedModel);
1584
1635
  const sessionForModelGate = await requireSession(db, workspaceId, sessionId);
1585
- const effectiveModelForGate = requestedModel ?? sessionForModelGate.model;
1636
+ // Acceptance already froze this policy before resource/credential validation.
1637
+ // A different turn starting meanwhile must not change the model we gate here.
1638
+ const effectiveModelForGate =
1639
+ input.turnExecutionPolicy?.productModelId ?? requestedModel ?? sessionForModelGate.model;
1586
1640
  const freshWorkspaceCustomModel =
1587
1641
  requestedModel !== null &&
1588
1642
  isWorkspaceCustomModelId(settings, requestedModel) &&
@@ -1760,6 +1814,87 @@ export function resolveSessionCreateVisibility(input: {
1760
1814
  return input.requestedVisibility === "private" ? "user_private" : "workspace_shared";
1761
1815
  }
1762
1816
 
1817
+ export type SessionCreateScope = {
1818
+ agentAccess: SessionAgentAccess;
1819
+ endUser: SessionEndUser | null;
1820
+ memoryScope: SessionMemoryScope;
1821
+ };
1822
+
1823
+ const AGENT_ACCESS_WIDTH: Record<SessionAgentAccess, number> = {
1824
+ session: 0,
1825
+ user: 1,
1826
+ workspace: 2,
1827
+ };
1828
+
1829
+ const MEMORY_SCOPE_WIDTH: Record<SessionMemoryScope, number> = {
1830
+ off: 0,
1831
+ session: 1,
1832
+ user: 2,
1833
+ workspace: 3,
1834
+ };
1835
+
1836
+ /**
1837
+ * Resolve a new session's agent-access scope, end-user label, and Memory
1838
+ * selector (migration 0427). A top-level request takes its own values. An
1839
+ * agent-created child inherits every omitted value from its trusted parent
1840
+ * and may only NARROW an explicit one: agent access workspace > user >
1841
+ * session, memory workspace > user > session > off, and the label must equal
1842
+ * the parent's. Widening is a 403 because the parent's declared reach is a
1843
+ * security boundary the child's own request cannot cross; a memory `user`
1844
+ * selector without a label is a 422 in either position.
1845
+ */
1846
+ export function resolveSessionCreateScope(input: {
1847
+ requested: {
1848
+ agentAccess: SessionAgentAccess;
1849
+ agentAccessProvided: boolean;
1850
+ endUser: SessionEndUser | null;
1851
+ endUserProvided: boolean;
1852
+ memoryScope: SessionMemoryScope;
1853
+ memoryScopeProvided: boolean;
1854
+ };
1855
+ parent: SessionCreateScope | null;
1856
+ }): SessionCreateScope {
1857
+ const { requested, parent } = input;
1858
+ let resolved: SessionCreateScope;
1859
+ if (!parent) {
1860
+ resolved = {
1861
+ agentAccess: requested.agentAccess,
1862
+ endUser: requested.endUser,
1863
+ memoryScope: requested.memoryScope,
1864
+ };
1865
+ } else {
1866
+ const agentAccess = requested.agentAccessProvided ? requested.agentAccess : parent.agentAccess;
1867
+ if (AGENT_ACCESS_WIDTH[agentAccess] > AGENT_ACCESS_WIDTH[parent.agentAccess]) {
1868
+ throw new HTTPException(403, {
1869
+ message: "child agent access may only narrow the parent session",
1870
+ });
1871
+ }
1872
+ if (requested.endUserProvided) {
1873
+ const same =
1874
+ requested.endUser !== null &&
1875
+ parent.endUser !== null &&
1876
+ requested.endUser.source === parent.endUser.source &&
1877
+ requested.endUser.id === parent.endUser.id;
1878
+ if (!same) {
1879
+ throw new HTTPException(403, {
1880
+ message: "child end-user label must equal the parent session label",
1881
+ });
1882
+ }
1883
+ }
1884
+ const memoryScope = requested.memoryScopeProvided ? requested.memoryScope : parent.memoryScope;
1885
+ if (MEMORY_SCOPE_WIDTH[memoryScope] > MEMORY_SCOPE_WIDTH[parent.memoryScope]) {
1886
+ throw new HTTPException(403, {
1887
+ message: "child memory scope may only narrow the parent session",
1888
+ });
1889
+ }
1890
+ resolved = { agentAccess, endUser: parent.endUser, memoryScope };
1891
+ }
1892
+ if (resolved.memoryScope === "user" && resolved.endUser === null) {
1893
+ throw new HTTPException(422, { message: 'memoryScope "user" requires an endUser label' });
1894
+ }
1895
+ return resolved;
1896
+ }
1897
+
1763
1898
  async function resolveWorkspaceModelBoundarySettings(
1764
1899
  deps: Pick<ApiRouteDeps, "db" | "settings" | "catalogSourceSettings">,
1765
1900
  grant: AccessGrant,
@@ -1832,6 +1967,7 @@ export async function createSessionForRequestWithOutcome(
1832
1967
  agentChildPresentation?: AgentChildSessionCreatePresentation,
1833
1968
  ): Promise<CreateSessionRequestOutcome> {
1834
1969
  const payload = CreateSessionRequest.parse(rawPayload);
1970
+ payload.metadata = sessionCreationMetadata(payload.metadata);
1835
1971
  if (hasReservedOpenGeniSlackBotSessionMetadata(payload.metadata)) {
1836
1972
  throw new HTTPException(422, {
1837
1973
  message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
@@ -1902,6 +2038,27 @@ export async function createSessionForRequestWithOutcome(
1902
2038
  message: error instanceof Error ? error.message : "invalid child visibility",
1903
2039
  });
1904
2040
  }
2041
+ // Agent-access/end-user/memory scope inherit and narrow exactly like
2042
+ // visibility: presence is read from the raw request because the Zod
2043
+ // defaults erase absent-vs-explicit, and the parent side comes from the
2044
+ // durable row rather than anything the caller sent.
2045
+ const sessionScope = resolveSessionCreateScope({
2046
+ requested: {
2047
+ agentAccess: payload.agentAccess,
2048
+ agentAccessProvided: hasOwnProperty(rawPayload, "agentAccess"),
2049
+ endUser: payload.endUser ?? null,
2050
+ endUserProvided: hasOwnProperty(rawPayload, "endUser"),
2051
+ memoryScope: payload.memoryScope,
2052
+ memoryScopeProvided: hasOwnProperty(rawPayload, "memoryScope"),
2053
+ },
2054
+ parent: parentAuthority
2055
+ ? {
2056
+ agentAccess: parentAuthority.agentAccess,
2057
+ endUser: parentAuthority.endUser,
2058
+ memoryScope: parentAuthority.memoryScope,
2059
+ }
2060
+ : null,
2061
+ });
1905
2062
  const creationInitiator = creationInitiatorForGrant(grant);
1906
2063
  const parentCallingTurn =
1907
2064
  parentSession && creationInitiator.actor
@@ -2434,7 +2591,7 @@ export async function createSessionForRequestWithOutcome(
2434
2591
  message: `first-party MCP tool is disabled by deployment policy: ${disallowedFirstPartyMcpTool}`,
2435
2592
  });
2436
2593
  }
2437
- const workspaceFirstPartyDefaults = workspaceSessionToolDefaults?.firstPartyMcpTools.filter(
2594
+ const workspaceFirstPartyDefaults = workspaceSessionToolDefaults?.firstPartyMcpTools?.filter(
2438
2595
  (tool) => deploymentFirstPartyMcpToolPolicy.allowed.includes(tool),
2439
2596
  );
2440
2597
  const firstPartyMcpTools = resolveFirstPartyMcpToolsForCreate(
@@ -2821,6 +2978,9 @@ export async function createSessionForRequestWithOutcome(
2821
2978
  // time. Not surfaced as an event.
2822
2979
  instructions: payload.instructions ?? null,
2823
2980
  policyRole: payload.policyRole ?? null,
2981
+ agentAccess: sessionScope.agentAccess,
2982
+ endUser: sessionScope.endUser,
2983
+ memoryScope: sessionScope.memoryScope,
2824
2984
  firstPartyMcpPermissions,
2825
2985
  firstPartyMcpTools,
2826
2986
  mcpServers: sessionMcpServers.dbServers,
@@ -3532,6 +3692,7 @@ export async function updateSessionToolPolicy(
3532
3692
  surface: "core",
3533
3693
  });
3534
3694
  requirePermission(grant, "sessions:control");
3695
+ const agentAttemptCaller = grantHasAgentAttemptAuthority(grant);
3535
3696
 
3536
3697
  const existingSession = await requireSession(deps.db, grant.workspaceId, sessionId);
3537
3698
  const workspace = await requireWorkspace(deps.db, grant.workspaceId);
@@ -3585,7 +3746,7 @@ export async function updateSessionToolPolicy(
3585
3746
  runtimeSettings,
3586
3747
  );
3587
3748
  const workspaceDefaultFirstPartyTools = [
3588
- ...(workspaceSessionToolDefaults?.firstPartyMcpTools.filter((tool) =>
3749
+ ...(workspaceSessionToolDefaults?.firstPartyMcpTools?.filter((tool) =>
3589
3750
  deploymentFirstPartyMcpToolPolicy.allowed.includes(tool),
3590
3751
  ) ?? deploymentFirstPartyMcpToolPolicy.default),
3591
3752
  ];
@@ -3670,6 +3831,41 @@ export async function updateSessionToolPolicy(
3670
3831
  ? workspaceDefaultFirstPartyTools
3671
3832
  : explicitRequestedFirstPartyTools!;
3672
3833
  nextPolicy = { mode: requestedMode, inheritedFromSessionId: null };
3834
+ if (agentAttemptCaller) {
3835
+ // A human or API key may widen a top-level session; a live agent
3836
+ // attempt may only narrow relative to the session's CURRENT
3837
+ // effective policy, in either mode. Adopting workspace defaults is a
3838
+ // widen whenever it adds a server or tool the session does not hold.
3839
+ const sessionTracksWorkspaceDefaults = session.toolPolicy?.mode === "workspace_default";
3840
+ const currentEffectiveTools = withFirstPartyTools(
3841
+ sessionTracksWorkspaceDefaults
3842
+ ? withWorkspaceDefaultMcpTools(
3843
+ availableToolRefs(session.tools, runtimeSettings),
3844
+ deps.settings,
3845
+ runtimeSettings,
3846
+ workspaceSessionToolDefaults,
3847
+ )
3848
+ : session.tools,
3849
+ runtimeSettings,
3850
+ );
3851
+ assertToolRefsSubset(
3852
+ nextTools,
3853
+ currentEffectiveTools,
3854
+ "an agent may only narrow its session tool policy",
3855
+ );
3856
+ const currentFirstPartyCeiling = effectiveFirstPartyMcpToolCeiling(
3857
+ session.firstPartyMcpTools,
3858
+ deploymentFirstPartyMcpToolPolicy,
3859
+ );
3860
+ const widenedFirstPartyTool = nextFirstPartyMcpTools.find(
3861
+ (tool) => !currentFirstPartyCeiling.has(tool),
3862
+ );
3863
+ if (widenedFirstPartyTool) {
3864
+ throw new HTTPException(403, {
3865
+ message: `an agent may only narrow its session OpenGeni tools: ${widenedFirstPartyTool}`,
3866
+ });
3867
+ }
3868
+ }
3673
3869
  }
3674
3870
 
3675
3871
  const currentPolicy = session.toolPolicy;
package/src/index.ts CHANGED
@@ -114,3 +114,4 @@ export * from "./editable-artifact-live";
114
114
 
115
115
  // Transport-neutral editable-artifact domain service, ports, and contracts.
116
116
  export * from "./editable-artifacts";
117
+ export { withSiteSessionOrigin } from "./site-session-origin";
@@ -1,3 +1,4 @@
1
+ import { modelAllowedByConnections, type ConnectionModelRestrictions } from "@opengeni/db";
1
2
  import {
2
3
  applyModelCatalogDocument,
3
4
  configuredGatewayWorkspaceProductModelIds,
@@ -379,6 +380,7 @@ export type ModelCredentialReadinessObservation =
379
380
  export const MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS = 5 * 60_000;
380
381
 
381
382
  export type WorkspaceModelSelectionInput = {
383
+ connectionModelRestrictions?: ConnectionModelRestrictions;
382
384
  settings: Settings;
383
385
  policy: WorkspaceModelPolicyContract | null;
384
386
  codexSubscriptionActive: boolean;
@@ -708,10 +710,11 @@ export function resolveWorkspaceModelSelection(
708
710
 
709
711
  return configuredModels(catalogSettings).map((model) => {
710
712
  const provider = providers.get(model.providerId);
711
- const policyAllowed = evaluateWorkspaceModelPolicy(input.policy, {
712
- providerId: model.providerId,
713
- modelId: model.id,
714
- }).allowed;
713
+ const policyAllowed =
714
+ evaluateWorkspaceModelPolicy(input.policy, {
715
+ providerId: model.providerId,
716
+ modelId: model.id,
717
+ }).allowed && modelAllowedByConnections(input.connectionModelRestrictions ?? {}, model.id);
715
718
  const credentialReadiness = credentialReadinessFor({
716
719
  model,
717
720
  provider,