llm-cli-gateway 2.9.0 → 2.10.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to the llm-cli-gateway project.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.10.0] - 2026-06-15: Per-principal isolation on the request handlers
8
+
9
+ A follow-up to the 2.9.0 per-principal isolation (F3): the ownership model was
10
+ enforced on the `session_*` / `llm_*` bookkeeping tools but **not** on the
11
+ `*_request` execution handlers, the workspace/worktree resolvers, or the
12
+ `sessions://*` resources. An adversarial multi-LLM review of the 2.9.0 surface
13
+ found two HIGH cross-principal bugs reachable in the opt-in remote/OAuth
14
+ multi-tenant modes; this release closes them. The default local-stdio and shared
15
+ static-bearer paths collapse to a single principal and are unaffected (no
16
+ behaviour change). Provider CLI release targets are unchanged from 2.8.0/2.9.0
17
+ (see `docs/upstream/release-targets.md`).
18
+
19
+ ### Security
20
+
21
+ - Cross-principal session takeover (F3b, request handlers):
22
+ `getExistingSessionForProvider` now rejects a caller-supplied session id owned
23
+ by a different principal — the ownership check is ordered before the
24
+ provider-type comparison, so a foreign session never leaks its provider. This
25
+ is the choke point for every `*_request` / `*_request_async` handler (claude,
26
+ codex, gemini, grok, mistral, grok-api) and `codex_fork_session`. Previously a
27
+ remote principal could resume or read another principal's conversation by
28
+ supplying its session id.
29
+ - Global active-session pointer is now owner-filtered at every request-handler
30
+ adoption site (and in the codex async path, which bypassed the choke point),
31
+ so a no-`sessionId` request can no longer adopt and resume another principal's
32
+ active session.
33
+ - Workspace-isolation bypass (F3b, resolvers): `resolveWorktreeForRequest` and
34
+ `resolveWorkspaceAndWorktreeForRequest` ignore a referenced session the caller
35
+ does not own, so a foreign session's metadata can no longer select the
36
+ workspace/worktree working directory or satisfy the remote "registered
37
+ workspace" gate.
38
+ - `sessions://*` resources are now owner-filtered: `sessions://all` and the five
39
+ per-provider session resources return only the caller's own rows and active
40
+ pointer, closing the session-id/metadata enumeration vector.
41
+
42
+ ### Tests
43
+
44
+ - Adds `src/__tests__/f3b-request-handler-isolation.test.ts` (cross-principal
45
+ deny-path coverage for the request handlers and the `sessions://*` resources).
46
+
7
47
  ## [2.9.0] - 2026-06-14: MCP-surface red-team remediation
8
48
 
9
49
  This release remediates all 17 findings of a multi-LLM red-team of the gateway's
package/dist/index.js CHANGED
@@ -434,7 +434,7 @@ export async function resolveWorktreeForRequest(worktreeOpt, sessionId, runtime,
434
434
  return {};
435
435
  const sessionManager = runtime.sessionManager;
436
436
  if (sessionId) {
437
- const session = await Promise.resolve(sessionManager.getSession(sessionId));
437
+ const session = await getCallerOwnedSession(sessionManager, sessionId);
438
438
  const existingPath = session?.metadata?.worktreePath;
439
439
  if (typeof existingPath === "string" && existingPath.length > 0) {
440
440
  return {
@@ -477,9 +477,7 @@ function isGatewayAppDirCwd() {
477
477
  return process.cwd() === join(homedir(), ".llm-cli-gateway");
478
478
  }
479
479
  async function resolveWorkspaceAndWorktreeForRequest(args) {
480
- const session = args.sessionId
481
- ? await Promise.resolve(args.runtime.sessionManager.getSession(args.sessionId))
482
- : null;
480
+ const session = await getCallerOwnedSession(args.runtime.sessionManager, args.sessionId);
483
481
  let workspace;
484
482
  if (args.workspace ||
485
483
  args.runtime.workspaces.defaultAlias ||
@@ -1972,11 +1970,33 @@ function usageFromXaiResult(result) {
1972
1970
  costUsd: result.usage.costUsd,
1973
1971
  };
1974
1972
  }
1973
+ function callerCanAccessSession(session) {
1974
+ return principalCanAccess(session.ownerPrincipal, resolveOwnerPrincipal(getRequestContext()));
1975
+ }
1976
+ async function getCallerOwnedSession(sessionManager, sessionId) {
1977
+ if (!sessionId)
1978
+ return null;
1979
+ const existing = await Promise.resolve(sessionManager.getSession(sessionId));
1980
+ if (!existing || !callerCanAccessSession(existing))
1981
+ return null;
1982
+ return existing;
1983
+ }
1984
+ async function getCallerOwnedActiveSession(sessionManager, provider) {
1985
+ const active = await Promise.resolve(sessionManager.getActiveSession(provider));
1986
+ if (!active || !callerCanAccessSession(active))
1987
+ return null;
1988
+ return active;
1989
+ }
1975
1990
  async function getExistingSessionForProvider(sessionManager, sessionId, provider) {
1976
1991
  if (!sessionId)
1977
1992
  return null;
1978
1993
  const existing = await sessionManager.getSession(sessionId);
1979
- if (existing && existing.cli !== provider) {
1994
+ if (!existing)
1995
+ return null;
1996
+ if (!callerCanAccessSession(existing)) {
1997
+ throw new Error(`Session ${sessionId} is not accessible`);
1998
+ }
1999
+ if (existing.cli !== provider) {
1980
2000
  throw new Error(`Session ${sessionId} belongs to provider '${existing.cli}', not '${provider}'`);
1981
2001
  }
1982
2002
  return existing;
@@ -2029,7 +2049,7 @@ async function resolveGrokApiSession(params, runtime) {
2029
2049
  return { sessionId: session.id, previousResponseId: previous };
2030
2050
  }
2031
2051
  if (!params.createNewSession) {
2032
- const active = await runtime.sessionManager.getActiveSession("grok-api");
2052
+ const active = await getCallerOwnedActiveSession(runtime.sessionManager, "grok-api");
2033
2053
  if (active) {
2034
2054
  const previous = typeof active.metadata?.xaiPreviousResponseId === "string"
2035
2055
  ? active.metadata.xaiPreviousResponseId
@@ -3056,7 +3076,7 @@ export async function handleCodexRequestAsync(deps, params) {
3056
3076
  try {
3057
3077
  let effectiveSessionId = params.sessionId;
3058
3078
  if (!params.createNewSession && !params.sessionId) {
3059
- const activeSession = await deps.sessionManager.getActiveSession("codex");
3079
+ const activeSession = await getCallerOwnedActiveSession(deps.sessionManager, "codex");
3060
3080
  if (activeSession) {
3061
3081
  effectiveSessionId = activeSession.id;
3062
3082
  }
@@ -3066,6 +3086,7 @@ export async function handleCodexRequestAsync(deps, params) {
3066
3086
  }
3067
3087
  }
3068
3088
  else if (params.sessionId) {
3089
+ await getExistingSessionForProvider(deps.sessionManager, params.sessionId, "codex");
3069
3090
  await deps.sessionManager.updateSessionUsage(params.sessionId);
3070
3091
  }
3071
3092
  else if (params.createNewSession) {
@@ -3406,7 +3427,7 @@ export function createGatewayServer(deps = {}) {
3406
3427
  let useContinue = continueSession;
3407
3428
  let activeSession = null;
3408
3429
  try {
3409
- activeSession = await sessionManager.getActiveSession("claude");
3430
+ activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
3410
3431
  }
3411
3432
  catch (err) {
3412
3433
  logger.warn(`[${corrId}] sessionManager.getActiveSession failed (non-fatal): ${err.message}`);
@@ -3775,7 +3796,7 @@ export function createGatewayServer(deps = {}) {
3775
3796
  wasSuccessful = true;
3776
3797
  let effectiveSessionId = sessionId;
3777
3798
  if (!createNewSession && !sessionId) {
3778
- const activeSession = await sessionManager.getActiveSession("codex");
3799
+ const activeSession = await getCallerOwnedActiveSession(sessionManager, "codex");
3779
3800
  if (activeSession) {
3780
3801
  effectiveSessionId = activeSession.id;
3781
3802
  }
@@ -4486,7 +4507,7 @@ export function createGatewayServer(deps = {}) {
4486
4507
  try {
4487
4508
  let effectiveSessionId = sessionId;
4488
4509
  let useContinue = continueSession;
4489
- const activeSession = await sessionManager.getActiveSession("claude");
4510
+ const activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
4490
4511
  if (!createNewSession && !continueSession && !sessionId && activeSession) {
4491
4512
  effectiveSessionId = activeSession.id;
4492
4513
  useContinue = true;
@@ -33,5 +33,7 @@ export declare class ResourceProvider {
33
33
  readCacheStateSession(sessionId: string): SessionCacheStats;
34
34
  readCacheStateForPrefix(stablePrefixHash: string): PrefixCacheStats;
35
35
  listResources(): ResourceDefinition[];
36
+ private ownedSessions;
37
+ private ownedActiveId;
36
38
  readResource(uri: string): Promise<ResourceContents | null>;
37
39
  }
package/dist/resources.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CLI_TYPES, PROVIDER_TYPES } from "./session-manager.js";
2
+ import { getRequestContext, principalCanAccess, resolveOwnerPrincipal } from "./request-context.js";
2
3
  import { getAvailableCliInfo } from "./model-registry.js";
3
4
  import { computeGlobalCacheStats, computePrefixCacheStats, computeSessionCacheStats, computeTtlRemaining, } from "./cache-stats.js";
4
5
  import { buildProviderSubcommandsCompactCatalog, getCliSubcommandContract, serializeCliSubcommandContract, } from "./upstream-contracts.js";
@@ -201,13 +202,21 @@ export class ResourceProvider {
201
202
  })),
202
203
  ];
203
204
  }
205
+ ownedSessions(sessions) {
206
+ const caller = resolveOwnerPrincipal(getRequestContext());
207
+ return sessions.filter(s => principalCanAccess(s.ownerPrincipal, caller));
208
+ }
209
+ async ownedActiveId(provider) {
210
+ const active = await Promise.resolve(this.sessionManager.getActiveSession(provider));
211
+ if (!active)
212
+ return null;
213
+ const caller = resolveOwnerPrincipal(getRequestContext());
214
+ return principalCanAccess(active.ownerPrincipal, caller) ? active.id : null;
215
+ }
204
216
  async readResource(uri) {
205
217
  if (uri === "sessions://all") {
206
- const sessions = await this.sessionManager.listSessions();
207
- const activeSessions = Object.fromEntries(await Promise.all(PROVIDER_TYPES.map(async (provider) => [
208
- provider,
209
- (await this.sessionManager.getActiveSession(provider))?.id || null,
210
- ])));
218
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions());
219
+ const activeSessions = Object.fromEntries(await Promise.all(PROVIDER_TYPES.map(async (provider) => [provider, await this.ownedActiveId(provider)])));
211
220
  return {
212
221
  uri,
213
222
  mimeType: "application/json",
@@ -225,7 +234,7 @@ export class ResourceProvider {
225
234
  };
226
235
  }
227
236
  if (uri === "sessions://claude") {
228
- const sessions = await this.sessionManager.listSessions("claude");
237
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions("claude"));
229
238
  return {
230
239
  uri,
231
240
  mimeType: "application/json",
@@ -233,12 +242,12 @@ export class ResourceProvider {
233
242
  cli: "claude",
234
243
  total: sessions.length,
235
244
  sessions,
236
- activeSession: (await this.sessionManager.getActiveSession("claude"))?.id || null,
245
+ activeSession: await this.ownedActiveId("claude"),
237
246
  }, null, 2),
238
247
  };
239
248
  }
240
249
  if (uri === "sessions://codex") {
241
- const sessions = await this.sessionManager.listSessions("codex");
250
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions("codex"));
242
251
  return {
243
252
  uri,
244
253
  mimeType: "application/json",
@@ -246,12 +255,12 @@ export class ResourceProvider {
246
255
  cli: "codex",
247
256
  total: sessions.length,
248
257
  sessions,
249
- activeSession: (await this.sessionManager.getActiveSession("codex"))?.id || null,
258
+ activeSession: await this.ownedActiveId("codex"),
250
259
  }, null, 2),
251
260
  };
252
261
  }
253
262
  if (uri === "sessions://gemini") {
254
- const sessions = await this.sessionManager.listSessions("gemini");
263
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions("gemini"));
255
264
  return {
256
265
  uri,
257
266
  mimeType: "application/json",
@@ -259,12 +268,12 @@ export class ResourceProvider {
259
268
  cli: "gemini",
260
269
  total: sessions.length,
261
270
  sessions,
262
- activeSession: (await this.sessionManager.getActiveSession("gemini"))?.id || null,
271
+ activeSession: await this.ownedActiveId("gemini"),
263
272
  }, null, 2),
264
273
  };
265
274
  }
266
275
  if (uri === "sessions://grok") {
267
- const sessions = await this.sessionManager.listSessions("grok");
276
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions("grok"));
268
277
  return {
269
278
  uri,
270
279
  mimeType: "application/json",
@@ -272,12 +281,12 @@ export class ResourceProvider {
272
281
  cli: "grok",
273
282
  total: sessions.length,
274
283
  sessions,
275
- activeSession: (await this.sessionManager.getActiveSession("grok"))?.id || null,
284
+ activeSession: await this.ownedActiveId("grok"),
276
285
  }, null, 2),
277
286
  };
278
287
  }
279
288
  if (uri === "sessions://mistral") {
280
- const sessions = await this.sessionManager.listSessions("mistral");
289
+ const sessions = this.ownedSessions(await this.sessionManager.listSessions("mistral"));
281
290
  return {
282
291
  uri,
283
292
  mimeType: "application/json",
@@ -285,7 +294,7 @@ export class ResourceProvider {
285
294
  cli: "mistral",
286
295
  total: sessions.length,
287
296
  sessions,
288
- activeSession: (await this.sessionManager.getActiveSession("mistral"))?.id || null,
297
+ activeSession: await this.ownedActiveId("mistral"),
289
298
  }, null, 2),
290
299
  };
291
300
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "llm-cli-gateway",
9
- "version": "2.9.0",
9
+ "version": "2.10.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.29.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
6
6
  "license": "MIT",