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 +40 -0
- package/dist/index.js +31 -10
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +24 -15
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4510
|
+
const activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
|
|
4490
4511
|
if (!createNewSession && !continueSession && !sessionId && activeSession) {
|
|
4491
4512
|
effectiveSessionId = activeSession.id;
|
|
4492
4513
|
useContinue = true;
|
package/dist/resources.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
297
|
+
activeSession: await this.ownedActiveId("mistral"),
|
|
289
298
|
}, null, 2),
|
|
290
299
|
};
|
|
291
300
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "2.
|
|
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
|
+
"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.
|
|
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",
|