llm-cli-gateway 2.8.0 → 2.9.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";
@@ -1274,7 +1274,8 @@ export function prepareClaudeRequest(params, runtime = resolveGatewayServerRunti
1274
1274
  args.push("--disallowed-tools", ...params.disallowedTools);
1275
1275
  }
1276
1276
  if (params.approvalStrategy === "mcp_managed") {
1277
- args.push("--permission-mode", "bypassPermissions");
1277
+ const managedMode = bypassAllowedByOperator() ? "bypassPermissions" : "acceptEdits";
1278
+ args.push("--permission-mode", managedMode);
1278
1279
  }
1279
1280
  else {
1280
1281
  const permFlags = resolveClaudePermissionFlags({
@@ -1539,7 +1540,11 @@ export function prepareGeminiRequest(params, runtime = resolveGatewayServerRunti
1539
1540
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1540
1541
  }
1541
1542
  }
1542
- const effectiveApprovalMode = params.approvalStrategy === "mcp_managed" ? "yolo" : params.approvalMode;
1543
+ const effectiveApprovalMode = params.approvalStrategy === "mcp_managed"
1544
+ ? bypassAllowedByOperator()
1545
+ ? "yolo"
1546
+ : "default"
1547
+ : params.approvalMode;
1543
1548
  const unsupported = (field, detail) => createErrorResponse(params.operation, 1, "", corrId, new Error(`${field} is not supported by Antigravity CLI (agy): ${detail}`));
1544
1549
  if (effectiveApprovalMode &&
1545
1550
  effectiveApprovalMode !== "default" &&
@@ -1646,14 +1651,22 @@ export function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime
1646
1651
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1647
1652
  }
1648
1653
  }
1649
- const effectiveAlwaysApprove = params.approvalStrategy === "mcp_managed" ? true : Boolean(params.alwaysApprove);
1654
+ const managedGrokBypass = params.approvalStrategy === "mcp_managed" && bypassAllowedByOperator();
1650
1655
  const grokContract = UPSTREAM_CLI_CONTRACTS.grok;
1651
1656
  const genParams = params;
1652
1657
  const args = ["-p", effectivePrompt];
1653
1658
  if (resolvedModel)
1654
1659
  args.push("--model", resolvedModel);
1655
1660
  args.push(...buildArgvFromGeneration(grokContract, GROK_GEN_OUTPUT_FORMAT, genParams));
1656
- if (effectiveAlwaysApprove) {
1661
+ if (params.approvalStrategy === "mcp_managed") {
1662
+ if (managedGrokBypass) {
1663
+ args.push("--always-approve");
1664
+ }
1665
+ else {
1666
+ args.push("--permission-mode", "acceptEdits");
1667
+ }
1668
+ }
1669
+ else if (Boolean(params.alwaysApprove)) {
1657
1670
  args.push("--always-approve");
1658
1671
  }
1659
1672
  else if (params.permissionMode) {
@@ -1760,7 +1773,9 @@ export function prepareMistralRequest(params, runtime = resolveGatewayServerRunt
1760
1773
  }
1761
1774
  }
1762
1775
  const effectivePermissionMode = params.approvalStrategy === "mcp_managed"
1763
- ? "auto-approve"
1776
+ ? bypassAllowedByOperator()
1777
+ ? "auto-approve"
1778
+ : "accept-edits"
1764
1779
  : (params.permissionMode ?? "auto-approve");
1765
1780
  const prep = buildMistralCliInvocation({
1766
1781
  prompt: effectivePrompt,
@@ -1811,7 +1826,9 @@ export function buildMistralRetryPrep(params, recoveryModel) {
1811
1826
  resolvedModel: recoveryModel,
1812
1827
  outputFormat: params.outputFormat,
1813
1828
  permissionMode: params.approvalStrategy === "mcp_managed"
1814
- ? "auto-approve"
1829
+ ? bypassAllowedByOperator()
1830
+ ? "auto-approve"
1831
+ : "accept-edits"
1815
1832
  : (params.permissionMode ?? "auto-approve"),
1816
1833
  allowedTools: params.allowedTools,
1817
1834
  disallowedTools: params.disallowedTools,
@@ -5158,7 +5175,8 @@ export function createGatewayServer(deps = {}) {
5158
5175
  openWorldHint: false,
5159
5176
  }, async ({ jobId }) => {
5160
5177
  const job = asyncJobManager.getJobSnapshot(jobId);
5161
- if (!job) {
5178
+ const caller = resolveOwnerPrincipal(getRequestContext());
5179
+ if (!job || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5162
5180
  return {
5163
5181
  content: [
5164
5182
  {
@@ -5202,7 +5220,8 @@ export function createGatewayServer(deps = {}) {
5202
5220
  openWorldHint: false,
5203
5221
  }, async ({ jobId, maxChars }) => {
5204
5222
  const result = asyncJobManager.getJobResult(jobId, maxChars);
5205
- if (!result) {
5223
+ const caller = resolveOwnerPrincipal(getRequestContext());
5224
+ if (!result || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5206
5225
  return {
5207
5226
  content: [
5208
5227
  {
@@ -5259,6 +5278,23 @@ export function createGatewayServer(deps = {}) {
5259
5278
  idempotentHint: true,
5260
5279
  openWorldHint: false,
5261
5280
  }, async ({ jobId }) => {
5281
+ const caller = resolveOwnerPrincipal(getRequestContext());
5282
+ if (asyncJobManager.getJobSnapshot(jobId) &&
5283
+ !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5284
+ return {
5285
+ content: [
5286
+ {
5287
+ type: "text",
5288
+ text: JSON.stringify({
5289
+ success: false,
5290
+ jobId,
5291
+ reason: "Job not found",
5292
+ }, null, 2),
5293
+ },
5294
+ ],
5295
+ isError: true,
5296
+ };
5297
+ }
5262
5298
  const cancel = asyncJobManager.cancelJob(jobId);
5263
5299
  if (!cancel.canceled) {
5264
5300
  return {
@@ -5315,7 +5351,8 @@ export function createGatewayServer(deps = {}) {
5315
5351
  maxChars,
5316
5352
  includePrompt,
5317
5353
  });
5318
- if (!record) {
5354
+ const caller = resolveOwnerPrincipal(getRequestContext());
5355
+ if (!record || !principalCanAccess(record.ownerPrincipal, caller)) {
5319
5356
  return {
5320
5357
  content: [
5321
5358
  {
@@ -5741,11 +5778,15 @@ export function createGatewayServer(deps = {}) {
5741
5778
  openWorldHint: false,
5742
5779
  }, async ({ cli }) => {
5743
5780
  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
- ])));
5781
+ const caller = resolveOwnerPrincipal(getRequestContext());
5782
+ const sessions = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
5783
+ const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => {
5784
+ const active = await sessionManager.getActiveSession(provider);
5785
+ return [
5786
+ provider,
5787
+ active && principalCanAccess(active.ownerPrincipal, caller) ? active : null,
5788
+ ];
5789
+ })));
5749
5790
  const sessionList = sessions.map(s => ({
5750
5791
  id: s.id,
5751
5792
  cli: s.cli,
@@ -5785,6 +5826,24 @@ export function createGatewayServer(deps = {}) {
5785
5826
  openWorldHint: false,
5786
5827
  }, async ({ cli, sessionId }) => {
5787
5828
  try {
5829
+ if (sessionId) {
5830
+ const caller = resolveOwnerPrincipal(getRequestContext());
5831
+ const target = await sessionManager.getSession(sessionId);
5832
+ if (!target || !principalCanAccess(target.ownerPrincipal, caller)) {
5833
+ return {
5834
+ content: [
5835
+ {
5836
+ type: "text",
5837
+ text: JSON.stringify({
5838
+ success: false,
5839
+ error: "Session not found or does not belong to the specified provider",
5840
+ }, null, 2),
5841
+ },
5842
+ ],
5843
+ isError: true,
5844
+ };
5845
+ }
5846
+ }
5788
5847
  const success = await sessionManager.setActiveSession(cli, sessionId || null);
5789
5848
  if (!success) {
5790
5849
  return {
@@ -5829,7 +5888,8 @@ export function createGatewayServer(deps = {}) {
5829
5888
  }, async ({ sessionId }) => {
5830
5889
  try {
5831
5890
  const session = await sessionManager.getSession(sessionId);
5832
- if (!session) {
5891
+ const caller = resolveOwnerPrincipal(getRequestContext());
5892
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5833
5893
  return {
5834
5894
  content: [
5835
5895
  {
@@ -5876,7 +5936,8 @@ export function createGatewayServer(deps = {}) {
5876
5936
  }, async ({ sessionId }) => {
5877
5937
  try {
5878
5938
  const session = await sessionManager.getSession(sessionId);
5879
- if (!session) {
5939
+ const caller = resolveOwnerPrincipal(getRequestContext());
5940
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5880
5941
  return {
5881
5942
  content: [
5882
5943
  {
@@ -5944,7 +6005,13 @@ export function createGatewayServer(deps = {}) {
5944
6005
  openWorldHint: false,
5945
6006
  }, async ({ cli }) => {
5946
6007
  try {
5947
- const count = await sessionManager.clearAllSessions(cli);
6008
+ const caller = resolveOwnerPrincipal(getRequestContext());
6009
+ const owned = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
6010
+ let count = 0;
6011
+ for (const s of owned) {
6012
+ if (await sessionManager.deleteSession(s.id))
6013
+ count++;
6014
+ }
5948
6015
  logger.info(`Cleared ${count} sessions${cli ? ` for ${cli}` : ""}`);
5949
6016
  return {
5950
6017
  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
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isRedactionEnabled(env?: NodeJS.ProcessEnv): boolean;
2
+ export declare function redactSecrets(text: string): string;
3
+ export declare function redactIfEnabled(value: string | null | undefined, enabled: boolean): typeof value;
@@ -0,0 +1,53 @@
1
+ const REDACTED = "[REDACTED]";
2
+ const RULES = [
3
+ {
4
+ label: "private-key",
5
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g,
6
+ },
7
+ { label: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
8
+ { label: "openai-key", pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}/g },
9
+ { label: "xai-key", pattern: /\bxai-[A-Za-z0-9]{16,}/g },
10
+ { label: "google-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
11
+ { label: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
12
+ { label: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
13
+ { label: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
14
+ {
15
+ label: "jwt",
16
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
17
+ },
18
+ {
19
+ label: "bearer",
20
+ pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/g,
21
+ replace: () => `Bearer ${REDACTED}`,
22
+ },
23
+ {
24
+ label: "url-credential",
25
+ pattern: /([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi,
26
+ replace: (_m, prefix) => `${prefix}:${REDACTED}@`,
27
+ },
28
+ {
29
+ label: "secret-assignment",
30
+ pattern: /\b(password|passwd|pwd|secret[_-]?key|client[_-]?secret|api[_-]?key|access[_-]?key|auth[_-]?token)\b(\s*[:=]\s*)(?:"[^"\n]{6,}"|'[^'\n]{6,}'|[^\s"'\n,;]{6,})/gi,
31
+ replace: (_m, key, sep) => `${key}${sep}${REDACTED}`,
32
+ },
33
+ ];
34
+ export function isRedactionEnabled(env = process.env) {
35
+ const raw = (env.LLM_GATEWAY_REDACT_LOGGED_SECRETS ?? "").trim().toLowerCase();
36
+ return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no";
37
+ }
38
+ export function redactSecrets(text) {
39
+ if (!text)
40
+ return text;
41
+ let out = text;
42
+ for (const rule of RULES) {
43
+ out = rule.replace
44
+ ? out.replace(rule.pattern, rule.replace)
45
+ : out.replace(rule.pattern, REDACTED);
46
+ }
47
+ return out;
48
+ }
49
+ export function redactIfEnabled(value, enabled) {
50
+ if (!enabled || !value)
51
+ return value;
52
+ return redactSecrets(value);
53
+ }