llm-cli-gateway 2.8.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/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import { clearModelRegistryCache, getAvailableCliInfo, getCliInfo, resolveModelA
25
25
  import { getProviderToolCapabilities } from "./provider-tool-capabilities.js";
26
26
  import { AsyncJobManager, } from "./async-job-manager.js";
27
27
  import { createJobStore } from "./job-store.js";
28
- import { ApprovalManager } from "./approval-manager.js";
28
+ import { ApprovalManager, bypassAllowedByOperator, } from "./approval-manager.js";
29
29
  import { checkReviewIntegrity } from "./review-integrity.js";
30
30
  import { buildClaudeMcpConfig, CLAUDE_MCP_SERVER_NAMES, } from "./claude-mcp-config.js";
31
31
  import { resolveGrokSessionArgs, resolveMistralSessionArgs, resolveCodexSessionArgs, sanitizeCliArgValues, prepareMistralRequest as buildMistralCliInvocation, MISTRAL_AGENT_MODES, GATEWAY_SESSION_PREFIX, resolveClaudePermissionFlags, resolveCodexSandboxFlags, CLAUDE_PERMISSION_MODES, GEMINI_APPROVAL_MODES, CODEX_SANDBOX_MODES, CODEX_ASK_FOR_APPROVAL_MODES, CLAUDE_EFFORT_LEVELS, prepareClaudeHighImpactFlags, validateClaudeAgentsMap, prepareCodexHighImpactFlags, prepareCodexForkRequest, CODEX_CONFIG_OVERRIDES_SCHEMA, resolveGeminiSessionPlan, GEMINI_HIGH_IMPACT_PARAMS_SCHEMA, } from "./request-helpers.js";
@@ -34,7 +34,7 @@ import { resolvePromptInput, PromptPartsSchema, assembleClaudeCacheBlocks, } fro
34
34
  import { computeSessionCacheStats, computeTtlRemaining, readPersistedRequest, PERSISTED_REQUEST_DEFAULT_MAX_CHARS, } from "./cache-stats.js";
35
35
  import { getCliVersions, runCliUpgrade } from "./cli-updater.js";
36
36
  import { startHttpGateway } from "./http-transport.js";
37
- import { getRequestContext } from "./request-context.js";
37
+ import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
38
38
  import { printDoctorJson } from "./doctor.js";
39
39
  import { createWorkspace, describeWorkspace, getWorkspace, loadWorkspaceRegistry, registerExistingWorkspace, resolveWorkspaceForProvider, validatePathInsideWorkspace, } from "./workspace-registry.js";
40
40
  import { generateSecret, hashSecret } from "./oauth.js";
@@ -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 ||
@@ -1274,7 +1272,8 @@ export function prepareClaudeRequest(params, runtime = resolveGatewayServerRunti
1274
1272
  args.push("--disallowed-tools", ...params.disallowedTools);
1275
1273
  }
1276
1274
  if (params.approvalStrategy === "mcp_managed") {
1277
- args.push("--permission-mode", "bypassPermissions");
1275
+ const managedMode = bypassAllowedByOperator() ? "bypassPermissions" : "acceptEdits";
1276
+ args.push("--permission-mode", managedMode);
1278
1277
  }
1279
1278
  else {
1280
1279
  const permFlags = resolveClaudePermissionFlags({
@@ -1539,7 +1538,11 @@ export function prepareGeminiRequest(params, runtime = resolveGatewayServerRunti
1539
1538
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1540
1539
  }
1541
1540
  }
1542
- const effectiveApprovalMode = params.approvalStrategy === "mcp_managed" ? "yolo" : params.approvalMode;
1541
+ const effectiveApprovalMode = params.approvalStrategy === "mcp_managed"
1542
+ ? bypassAllowedByOperator()
1543
+ ? "yolo"
1544
+ : "default"
1545
+ : params.approvalMode;
1543
1546
  const unsupported = (field, detail) => createErrorResponse(params.operation, 1, "", corrId, new Error(`${field} is not supported by Antigravity CLI (agy): ${detail}`));
1544
1547
  if (effectiveApprovalMode &&
1545
1548
  effectiveApprovalMode !== "default" &&
@@ -1646,14 +1649,22 @@ export function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime
1646
1649
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1647
1650
  }
1648
1651
  }
1649
- const effectiveAlwaysApprove = params.approvalStrategy === "mcp_managed" ? true : Boolean(params.alwaysApprove);
1652
+ const managedGrokBypass = params.approvalStrategy === "mcp_managed" && bypassAllowedByOperator();
1650
1653
  const grokContract = UPSTREAM_CLI_CONTRACTS.grok;
1651
1654
  const genParams = params;
1652
1655
  const args = ["-p", effectivePrompt];
1653
1656
  if (resolvedModel)
1654
1657
  args.push("--model", resolvedModel);
1655
1658
  args.push(...buildArgvFromGeneration(grokContract, GROK_GEN_OUTPUT_FORMAT, genParams));
1656
- if (effectiveAlwaysApprove) {
1659
+ if (params.approvalStrategy === "mcp_managed") {
1660
+ if (managedGrokBypass) {
1661
+ args.push("--always-approve");
1662
+ }
1663
+ else {
1664
+ args.push("--permission-mode", "acceptEdits");
1665
+ }
1666
+ }
1667
+ else if (Boolean(params.alwaysApprove)) {
1657
1668
  args.push("--always-approve");
1658
1669
  }
1659
1670
  else if (params.permissionMode) {
@@ -1760,7 +1771,9 @@ export function prepareMistralRequest(params, runtime = resolveGatewayServerRunt
1760
1771
  }
1761
1772
  }
1762
1773
  const effectivePermissionMode = params.approvalStrategy === "mcp_managed"
1763
- ? "auto-approve"
1774
+ ? bypassAllowedByOperator()
1775
+ ? "auto-approve"
1776
+ : "accept-edits"
1764
1777
  : (params.permissionMode ?? "auto-approve");
1765
1778
  const prep = buildMistralCliInvocation({
1766
1779
  prompt: effectivePrompt,
@@ -1811,7 +1824,9 @@ export function buildMistralRetryPrep(params, recoveryModel) {
1811
1824
  resolvedModel: recoveryModel,
1812
1825
  outputFormat: params.outputFormat,
1813
1826
  permissionMode: params.approvalStrategy === "mcp_managed"
1814
- ? "auto-approve"
1827
+ ? bypassAllowedByOperator()
1828
+ ? "auto-approve"
1829
+ : "accept-edits"
1815
1830
  : (params.permissionMode ?? "auto-approve"),
1816
1831
  allowedTools: params.allowedTools,
1817
1832
  disallowedTools: params.disallowedTools,
@@ -1955,11 +1970,33 @@ function usageFromXaiResult(result) {
1955
1970
  costUsd: result.usage.costUsd,
1956
1971
  };
1957
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
+ }
1958
1990
  async function getExistingSessionForProvider(sessionManager, sessionId, provider) {
1959
1991
  if (!sessionId)
1960
1992
  return null;
1961
1993
  const existing = await sessionManager.getSession(sessionId);
1962
- 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) {
1963
2000
  throw new Error(`Session ${sessionId} belongs to provider '${existing.cli}', not '${provider}'`);
1964
2001
  }
1965
2002
  return existing;
@@ -2012,7 +2049,7 @@ async function resolveGrokApiSession(params, runtime) {
2012
2049
  return { sessionId: session.id, previousResponseId: previous };
2013
2050
  }
2014
2051
  if (!params.createNewSession) {
2015
- const active = await runtime.sessionManager.getActiveSession("grok-api");
2052
+ const active = await getCallerOwnedActiveSession(runtime.sessionManager, "grok-api");
2016
2053
  if (active) {
2017
2054
  const previous = typeof active.metadata?.xaiPreviousResponseId === "string"
2018
2055
  ? active.metadata.xaiPreviousResponseId
@@ -3039,7 +3076,7 @@ export async function handleCodexRequestAsync(deps, params) {
3039
3076
  try {
3040
3077
  let effectiveSessionId = params.sessionId;
3041
3078
  if (!params.createNewSession && !params.sessionId) {
3042
- const activeSession = await deps.sessionManager.getActiveSession("codex");
3079
+ const activeSession = await getCallerOwnedActiveSession(deps.sessionManager, "codex");
3043
3080
  if (activeSession) {
3044
3081
  effectiveSessionId = activeSession.id;
3045
3082
  }
@@ -3049,6 +3086,7 @@ export async function handleCodexRequestAsync(deps, params) {
3049
3086
  }
3050
3087
  }
3051
3088
  else if (params.sessionId) {
3089
+ await getExistingSessionForProvider(deps.sessionManager, params.sessionId, "codex");
3052
3090
  await deps.sessionManager.updateSessionUsage(params.sessionId);
3053
3091
  }
3054
3092
  else if (params.createNewSession) {
@@ -3389,7 +3427,7 @@ export function createGatewayServer(deps = {}) {
3389
3427
  let useContinue = continueSession;
3390
3428
  let activeSession = null;
3391
3429
  try {
3392
- activeSession = await sessionManager.getActiveSession("claude");
3430
+ activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
3393
3431
  }
3394
3432
  catch (err) {
3395
3433
  logger.warn(`[${corrId}] sessionManager.getActiveSession failed (non-fatal): ${err.message}`);
@@ -3758,7 +3796,7 @@ export function createGatewayServer(deps = {}) {
3758
3796
  wasSuccessful = true;
3759
3797
  let effectiveSessionId = sessionId;
3760
3798
  if (!createNewSession && !sessionId) {
3761
- const activeSession = await sessionManager.getActiveSession("codex");
3799
+ const activeSession = await getCallerOwnedActiveSession(sessionManager, "codex");
3762
3800
  if (activeSession) {
3763
3801
  effectiveSessionId = activeSession.id;
3764
3802
  }
@@ -4469,7 +4507,7 @@ export function createGatewayServer(deps = {}) {
4469
4507
  try {
4470
4508
  let effectiveSessionId = sessionId;
4471
4509
  let useContinue = continueSession;
4472
- const activeSession = await sessionManager.getActiveSession("claude");
4510
+ const activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
4473
4511
  if (!createNewSession && !continueSession && !sessionId && activeSession) {
4474
4512
  effectiveSessionId = activeSession.id;
4475
4513
  useContinue = true;
@@ -5158,7 +5196,8 @@ export function createGatewayServer(deps = {}) {
5158
5196
  openWorldHint: false,
5159
5197
  }, async ({ jobId }) => {
5160
5198
  const job = asyncJobManager.getJobSnapshot(jobId);
5161
- if (!job) {
5199
+ const caller = resolveOwnerPrincipal(getRequestContext());
5200
+ if (!job || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5162
5201
  return {
5163
5202
  content: [
5164
5203
  {
@@ -5202,7 +5241,8 @@ export function createGatewayServer(deps = {}) {
5202
5241
  openWorldHint: false,
5203
5242
  }, async ({ jobId, maxChars }) => {
5204
5243
  const result = asyncJobManager.getJobResult(jobId, maxChars);
5205
- if (!result) {
5244
+ const caller = resolveOwnerPrincipal(getRequestContext());
5245
+ if (!result || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5206
5246
  return {
5207
5247
  content: [
5208
5248
  {
@@ -5259,6 +5299,23 @@ export function createGatewayServer(deps = {}) {
5259
5299
  idempotentHint: true,
5260
5300
  openWorldHint: false,
5261
5301
  }, async ({ jobId }) => {
5302
+ const caller = resolveOwnerPrincipal(getRequestContext());
5303
+ if (asyncJobManager.getJobSnapshot(jobId) &&
5304
+ !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5305
+ return {
5306
+ content: [
5307
+ {
5308
+ type: "text",
5309
+ text: JSON.stringify({
5310
+ success: false,
5311
+ jobId,
5312
+ reason: "Job not found",
5313
+ }, null, 2),
5314
+ },
5315
+ ],
5316
+ isError: true,
5317
+ };
5318
+ }
5262
5319
  const cancel = asyncJobManager.cancelJob(jobId);
5263
5320
  if (!cancel.canceled) {
5264
5321
  return {
@@ -5315,7 +5372,8 @@ export function createGatewayServer(deps = {}) {
5315
5372
  maxChars,
5316
5373
  includePrompt,
5317
5374
  });
5318
- if (!record) {
5375
+ const caller = resolveOwnerPrincipal(getRequestContext());
5376
+ if (!record || !principalCanAccess(record.ownerPrincipal, caller)) {
5319
5377
  return {
5320
5378
  content: [
5321
5379
  {
@@ -5741,11 +5799,15 @@ export function createGatewayServer(deps = {}) {
5741
5799
  openWorldHint: false,
5742
5800
  }, async ({ cli }) => {
5743
5801
  try {
5744
- const sessions = await sessionManager.listSessions(cli);
5745
- const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => [
5746
- provider,
5747
- await sessionManager.getActiveSession(provider),
5748
- ])));
5802
+ const caller = resolveOwnerPrincipal(getRequestContext());
5803
+ const sessions = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
5804
+ const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => {
5805
+ const active = await sessionManager.getActiveSession(provider);
5806
+ return [
5807
+ provider,
5808
+ active && principalCanAccess(active.ownerPrincipal, caller) ? active : null,
5809
+ ];
5810
+ })));
5749
5811
  const sessionList = sessions.map(s => ({
5750
5812
  id: s.id,
5751
5813
  cli: s.cli,
@@ -5785,6 +5847,24 @@ export function createGatewayServer(deps = {}) {
5785
5847
  openWorldHint: false,
5786
5848
  }, async ({ cli, sessionId }) => {
5787
5849
  try {
5850
+ if (sessionId) {
5851
+ const caller = resolveOwnerPrincipal(getRequestContext());
5852
+ const target = await sessionManager.getSession(sessionId);
5853
+ if (!target || !principalCanAccess(target.ownerPrincipal, caller)) {
5854
+ return {
5855
+ content: [
5856
+ {
5857
+ type: "text",
5858
+ text: JSON.stringify({
5859
+ success: false,
5860
+ error: "Session not found or does not belong to the specified provider",
5861
+ }, null, 2),
5862
+ },
5863
+ ],
5864
+ isError: true,
5865
+ };
5866
+ }
5867
+ }
5788
5868
  const success = await sessionManager.setActiveSession(cli, sessionId || null);
5789
5869
  if (!success) {
5790
5870
  return {
@@ -5829,7 +5909,8 @@ export function createGatewayServer(deps = {}) {
5829
5909
  }, async ({ sessionId }) => {
5830
5910
  try {
5831
5911
  const session = await sessionManager.getSession(sessionId);
5832
- if (!session) {
5912
+ const caller = resolveOwnerPrincipal(getRequestContext());
5913
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5833
5914
  return {
5834
5915
  content: [
5835
5916
  {
@@ -5876,7 +5957,8 @@ export function createGatewayServer(deps = {}) {
5876
5957
  }, async ({ sessionId }) => {
5877
5958
  try {
5878
5959
  const session = await sessionManager.getSession(sessionId);
5879
- if (!session) {
5960
+ const caller = resolveOwnerPrincipal(getRequestContext());
5961
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5880
5962
  return {
5881
5963
  content: [
5882
5964
  {
@@ -5944,7 +6026,13 @@ export function createGatewayServer(deps = {}) {
5944
6026
  openWorldHint: false,
5945
6027
  }, async ({ cli }) => {
5946
6028
  try {
5947
- const count = await sessionManager.clearAllSessions(cli);
6029
+ const caller = resolveOwnerPrincipal(getRequestContext());
6030
+ const owned = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
6031
+ let count = 0;
6032
+ for (const s of owned) {
6033
+ if (await sessionManager.deleteSession(s.id))
6034
+ count++;
6035
+ }
5948
6036
  logger.info(`Cleared ${count} sessions${cli ? ` for ${cli}` : ""}`);
5949
6037
  return {
5950
6038
  content: [
@@ -18,6 +18,7 @@ export interface JobRecord {
18
18
  finishedAt: string | null;
19
19
  pid: number | null;
20
20
  expiresAt: string;
21
+ ownerPrincipal: string | null;
21
22
  }
22
23
  export declare function resolveJobStoreDbPath(): string | null;
23
24
  export declare function resolveJobRetentionMs(): number;
@@ -33,6 +34,7 @@ export interface JobStore {
33
34
  outputFormat?: string;
34
35
  startedAt: string;
35
36
  pid: number | null;
37
+ ownerPrincipal?: string | null;
36
38
  }): void;
37
39
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
38
40
  recordComplete(input: {
@@ -88,6 +90,7 @@ export declare class SqliteJobStore implements JobStore {
88
90
  outputFormat?: string;
89
91
  startedAt: string;
90
92
  pid: number | null;
93
+ ownerPrincipal?: string | null;
91
94
  }): void;
92
95
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
93
96
  recordComplete(input: {
@@ -127,6 +130,7 @@ export declare class MemoryJobStore implements JobStore {
127
130
  outputFormat?: string;
128
131
  startedAt: string;
129
132
  pid: number | null;
133
+ ownerPrincipal?: string | null;
130
134
  }): void;
131
135
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
132
136
  recordComplete(input: {
package/dist/job-store.js CHANGED
@@ -57,8 +57,16 @@ function rowToRecord(row) {
57
57
  finishedAt: row.finished_at,
58
58
  pid: row.pid,
59
59
  expiresAt: row.expires_at,
60
+ ownerPrincipal: row.owner_principal ?? null,
60
61
  };
61
62
  }
63
+ function ensureJobsOwnerColumn(db) {
64
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
65
+ const hasOwner = cols.some(col => col?.name === "owner_principal");
66
+ if (!hasOwner) {
67
+ db.exec("ALTER TABLE jobs ADD COLUMN owner_principal TEXT");
68
+ }
69
+ }
62
70
  export class SqliteJobStore {
63
71
  logger;
64
72
  db;
@@ -94,13 +102,15 @@ export class SqliteJobStore {
94
102
  started_at TEXT NOT NULL,
95
103
  finished_at TEXT,
96
104
  pid INTEGER,
97
- expires_at TEXT NOT NULL
105
+ expires_at TEXT NOT NULL,
106
+ owner_principal TEXT
98
107
  );
99
108
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
100
109
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
101
110
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
102
111
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
103
112
  `);
113
+ ensureJobsOwnerColumn(this.db);
104
114
  if (process.platform !== "win32") {
105
115
  try {
106
116
  chmodSync(dbPath, 0o600);
@@ -113,10 +123,10 @@ export class SqliteJobStore {
113
123
  this.insertStmt = this.db.prepare(`
114
124
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
115
125
  status, exit_code, stdout, stderr, output_truncated, error,
116
- started_at, finished_at, pid, expires_at)
126
+ started_at, finished_at, pid, expires_at, owner_principal)
117
127
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
118
128
  @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
119
- @started_at, @finished_at, @pid, @expires_at)
129
+ @started_at, @finished_at, @pid, @expires_at, @owner_principal)
120
130
  `);
121
131
  this.updateOutputStmt = this.db.prepare(`
122
132
  UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
@@ -163,12 +173,13 @@ export class SqliteJobStore {
163
173
  exit_code: null,
164
174
  stdout: "",
165
175
  stderr: "",
166
- output_truncated: 0,
167
176
  error: null,
177
+ output_truncated: 0,
168
178
  started_at: input.startedAt,
169
179
  finished_at: null,
170
180
  pid: input.pid,
171
181
  expires_at: FAR_FUTURE_ISO,
182
+ owner_principal: input.ownerPrincipal ?? null,
172
183
  });
173
184
  }
174
185
  recordOutput(id, stdout, stderr, outputTruncated) {
@@ -258,6 +269,7 @@ export class MemoryJobStore {
258
269
  finishedAt: null,
259
270
  pid: input.pid,
260
271
  expiresAt: FAR_FUTURE_ISO,
272
+ ownerPrincipal: input.ownerPrincipal ?? null,
261
273
  });
262
274
  }
263
275
  recordOutput(id, stdout, stderr, outputTruncated) {
package/dist/oauth.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare class OAuthServer {
33
33
  private registrationAllowedByPolicy;
34
34
  private handleRegister;
35
35
  private handleAuthorize;
36
+ private verifyConsent;
37
+ private renderConsentPage;
36
38
  private handleToken;
37
39
  private pruneExpiredCodes;
38
40
  }
package/dist/oauth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import { URLSearchParams } from "node:url";
3
+ import { readCappedRawBody, maxOAuthBodyBytes } from "./request-limits.js";
3
4
  import { issueOAuthAccessToken, timingSafeStringEqual, } from "./auth.js";
4
5
  export const OAUTH_CODE_TTL_MS = 5 * 60 * 1000;
5
6
  const GENERATED_SECRET_BYTES = 32;
@@ -52,6 +53,30 @@ export function redactSecret(value) {
52
53
  function firstHeader(value) {
53
54
  return Array.isArray(value) ? value[0] : value;
54
55
  }
56
+ const HTML_ESCAPES = {
57
+ "&": "&",
58
+ "<": "&lt;",
59
+ ">": "&gt;",
60
+ '"': "&quot;",
61
+ "'": "&#39;",
62
+ };
63
+ function escapeHtml(value) {
64
+ return value.replace(/[&<>"']/g, char => HTML_ESCAPES[char] ?? char);
65
+ }
66
+ function readCookie(req, name) {
67
+ const header = firstHeader(req.headers.cookie);
68
+ if (!header)
69
+ return null;
70
+ for (const part of header.split(";")) {
71
+ const idx = part.indexOf("=");
72
+ if (idx < 0)
73
+ continue;
74
+ if (part.slice(0, idx).trim() === name) {
75
+ return decodeURIComponent(part.slice(idx + 1).trim());
76
+ }
77
+ }
78
+ return null;
79
+ }
55
80
  function methodNotAllowed(res) {
56
81
  res.writeHead(405, { allow: "GET, POST", "content-type": "application/json" });
57
82
  res.end(JSON.stringify({ error: "Method not allowed" }));
@@ -105,12 +130,7 @@ function extractStringArray(value, params, key) {
105
130
  return values.filter((item) => typeof item === "string" && item.length > 0);
106
131
  }
107
132
  async function readRawBody(req) {
108
- return new Promise((resolve, reject) => {
109
- const chunks = [];
110
- req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
111
- req.on("error", reject);
112
- req.on("end", () => resolve(chunks.length ? Buffer.concat(chunks).toString("utf8") : ""));
113
- });
133
+ return readCappedRawBody(req, maxOAuthBodyBytes());
114
134
  }
115
135
  async function readOAuthBody(req) {
116
136
  const raw = await readRawBody(req);
@@ -272,8 +292,7 @@ export class OAuthServer {
272
292
  registrationAllowedByPolicy(req, params) {
273
293
  const policy = this.opts.config.registrationPolicy;
274
294
  if (policy === "open_dev") {
275
- const host = firstHeader(req.headers.host) ?? "";
276
- return isLocalHost(host) || process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
295
+ return process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
277
296
  }
278
297
  if (policy === "static_clients")
279
298
  return false;
@@ -372,6 +391,17 @@ export class OAuthServer {
372
391
  res.end();
373
392
  return;
374
393
  }
394
+ if (this.opts.config.requireConsent) {
395
+ const isSubmission = req.method === "POST" && params.get("gw_consent") === "1";
396
+ if (!isSubmission) {
397
+ this.renderConsentPage(res, params, clientId, requestedScopes);
398
+ return;
399
+ }
400
+ if (!this.verifyConsent(req, params)) {
401
+ this.renderConsentPage(res, params, clientId, requestedScopes, "Incorrect access code.");
402
+ return;
403
+ }
404
+ }
375
405
  this.pruneExpiredCodes();
376
406
  const code = randomUUID();
377
407
  this.codes.set(code, {
@@ -389,6 +419,58 @@ export class OAuthServer {
389
419
  res.writeHead(302, { location: target.toString() });
390
420
  res.end();
391
421
  }
422
+ verifyConsent(req, params) {
423
+ const cookie = readCookie(req, "gw_oauth_csrf");
424
+ const formCsrf = params.get("gw_csrf");
425
+ if (!cookie || !formCsrf || !timingSafeStringEqual(cookie, formCsrf))
426
+ return false;
427
+ const secret = params.get("consent_secret") ?? "";
428
+ const hash = this.opts.config.consentSecretHash;
429
+ return Boolean(hash && secret && verifySecret(secret, hash));
430
+ }
431
+ renderConsentPage(res, params, clientId, requestedScopes, error) {
432
+ const csrf = randomUUID();
433
+ const carried = [
434
+ ["response_type", params.get("response_type")],
435
+ ["client_id", clientId],
436
+ ["redirect_uri", params.get("redirect_uri")],
437
+ ["scope", params.get("scope")],
438
+ ["state", params.get("state")],
439
+ ["code_challenge", params.get("code_challenge")],
440
+ ["code_challenge_method", params.get("code_challenge_method")],
441
+ ["gw_consent", "1"],
442
+ ["gw_csrf", csrf],
443
+ ];
444
+ const hidden = carried
445
+ .filter(([, value]) => value != null && value !== "")
446
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
447
+ .join("\n ");
448
+ const scopeList = requestedScopes.map(escapeHtml).join(", ");
449
+ const errorBlock = error ? `<p class="err">${escapeHtml(error)}</p>` : "";
450
+ const html = `<!doctype html>
451
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
452
+ <title>Authorize access</title>
453
+ <style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}
454
+ .box{border:1px solid #ddd;border-radius:8px;padding:1.5rem}label{display:block;margin:.75rem 0 .25rem}
455
+ input[type=password]{width:100%;padding:.5rem;box-sizing:border-box}button{margin-top:1rem;padding:.6rem 1.2rem}
456
+ .err{color:#b00020}.muted{color:#555;font-size:.9rem}</style></head>
457
+ <body><div class="box"><h2>Authorize access</h2>
458
+ <p>Application <strong>${escapeHtml(clientId)}</strong> is requesting access to: <strong>${scopeList}</strong>.</p>
459
+ ${errorBlock}
460
+ <form method="post" autocomplete="off">
461
+ ${hidden}
462
+ <label for="consent_secret">Gateway access code</label>
463
+ <input id="consent_secret" type="password" name="consent_secret" required autofocus>
464
+ <button type="submit">Approve</button>
465
+ </form>
466
+ <p class="muted">Only approve if you initiated this connection.</p></div></body></html>`;
467
+ res.writeHead(200, {
468
+ "content-type": "text/html; charset=utf-8",
469
+ "set-cookie": `gw_oauth_csrf=${csrf}; HttpOnly; SameSite=Lax; Path=/oauth`,
470
+ "cache-control": "no-store",
471
+ });
472
+ res.end(html);
473
+ }
392
474
  async handleToken(req, res) {
393
475
  if (req.method !== "POST") {
394
476
  methodNotAllowed(res);
@@ -3,6 +3,9 @@ export interface GatewayRequestContext {
3
3
  authKind?: "disabled" | "gateway_bearer" | "oauth";
4
4
  authScopes: string[];
5
5
  authClientId?: string;
6
+ authPrincipal?: string;
6
7
  }
8
+ export declare function resolveOwnerPrincipal(ctx: GatewayRequestContext | undefined): string;
9
+ export declare function principalCanAccess(rowOwner: string | null | undefined, caller: string): boolean;
7
10
  export declare function runWithRequestContext<T>(context: GatewayRequestContext, callback: () => T | Promise<T>): T | Promise<T>;
8
11
  export declare function getRequestContext(): GatewayRequestContext | undefined;
@@ -1,5 +1,21 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  const requestContext = new AsyncLocalStorage();
3
+ export function resolveOwnerPrincipal(ctx) {
4
+ if (!ctx)
5
+ return "local";
6
+ if (ctx.authPrincipal)
7
+ return ctx.authPrincipal;
8
+ if (ctx.authKind === "gateway_bearer")
9
+ return "gateway-bearer";
10
+ return "local";
11
+ }
12
+ export function principalCanAccess(rowOwner, caller) {
13
+ if (rowOwner === caller)
14
+ return true;
15
+ if ((rowOwner === null || rowOwner === undefined) && caller === "local")
16
+ return true;
17
+ return false;
18
+ }
3
19
  export function runWithRequestContext(context, callback) {
4
20
  return requestContext.run(context, callback);
5
21
  }
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare class PayloadTooLargeError extends Error {
3
+ readonly statusCode = 413;
4
+ constructor(maxBytes: number);
5
+ }
6
+ export declare function maxHttpBodyBytes(env?: NodeJS.ProcessEnv): number;
7
+ export declare function maxOAuthBodyBytes(env?: NodeJS.ProcessEnv): number;
8
+ export declare function readCappedRawBody(req: IncomingMessage, maxBytes: number): Promise<string>;
@@ -0,0 +1,49 @@
1
+ export class PayloadTooLargeError extends Error {
2
+ statusCode = 413;
3
+ constructor(maxBytes) {
4
+ super(`Request body exceeds maximum size (${maxBytes} bytes)`);
5
+ this.name = "PayloadTooLargeError";
6
+ }
7
+ }
8
+ const DEFAULT_MAX_HTTP_BODY_BYTES = 8 * 1024 * 1024;
9
+ const DEFAULT_MAX_OAUTH_BODY_BYTES = 64 * 1024;
10
+ function positiveIntFromEnv(value, fallback) {
11
+ const parsed = Number(value);
12
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
13
+ }
14
+ export function maxHttpBodyBytes(env = process.env) {
15
+ return positiveIntFromEnv(env.LLM_GATEWAY_MAX_HTTP_BODY_BYTES, DEFAULT_MAX_HTTP_BODY_BYTES);
16
+ }
17
+ export function maxOAuthBodyBytes(env = process.env) {
18
+ return positiveIntFromEnv(env.LLM_GATEWAY_MAX_OAUTH_BODY_BYTES, DEFAULT_MAX_OAUTH_BODY_BYTES);
19
+ }
20
+ export function readCappedRawBody(req, maxBytes) {
21
+ return new Promise((resolve, reject) => {
22
+ const chunks = [];
23
+ let total = 0;
24
+ let aborted = false;
25
+ req.on("data", (chunk) => {
26
+ if (aborted)
27
+ return;
28
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ total += buf.length;
30
+ if (total > maxBytes) {
31
+ aborted = true;
32
+ chunks.length = 0;
33
+ reject(new PayloadTooLargeError(maxBytes));
34
+ req.resume();
35
+ return;
36
+ }
37
+ chunks.push(buf);
38
+ });
39
+ req.on("error", err => {
40
+ if (!aborted)
41
+ reject(err);
42
+ });
43
+ req.on("end", () => {
44
+ if (aborted)
45
+ return;
46
+ resolve(chunks.length === 0 ? "" : Buffer.concat(chunks).toString("utf8"));
47
+ });
48
+ });
49
+ }