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/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ 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
+
47
+ ## [2.9.0] - 2026-06-14: MCP-surface red-team remediation
48
+
49
+ This release remediates all 17 findings of a multi-LLM red-team of the gateway's
50
+ external and internal MCP surface. Every change is material only in the opt-in
51
+ remote/OAuth/multi-tenant modes or under `approvalStrategy:"mcp_managed"`; the
52
+ default local-stdio path is unaffected. Provider CLI release targets are
53
+ unchanged from 2.8.0 (see `docs/upstream/release-targets.md`).
54
+
55
+ ### Security
56
+
57
+ - Per-principal isolation (F3): `session_*`, `llm_job_*`, and
58
+ `llm_request_result` now resolve a calling owner principal and enforce
59
+ own-or-not-found access, so one OAuth client can no longer read or mutate
60
+ another client's sessions, jobs, or persisted request results. Owner columns
61
+ are additive and nullable; legacy unowned rows remain accessible to local
62
+ callers. The shared static bearer is one principal by design.
63
+ - Built-in OAuth server hardening (F14): the authorization-code flow gains an
64
+ opt-in human-consent gate (dedicated consent password, default off) and a
65
+ trusted-principal-header seam so a proxy front door can attribute requests
66
+ without the gateway shipping any identity-provider configuration.
67
+ - Approval-gate hard boundary (F15): under `approvalStrategy:"mcp_managed"` a
68
+ full permission/sandbox bypass request is now denied by default regardless of
69
+ heuristic score, and the managed strategy no longer force-sets each provider's
70
+ most-permissive mode. See "Changed" for the per-provider defaults.
71
+ - Secret redaction (F4): prompts and responses are redacted before they are
72
+ written to the flight-recorder database.
73
+ - Remote-exposure fail-closed (F17): a remotely-exposed open OAuth configuration
74
+ refuses to start, and the `Host` header is no longer trusted for loopback
75
+ determination.
76
+ - MCP surface hardening (F1/F2/F10): request bodies are capped to prevent a
77
+ memory DoS, `cli_upgrade` targets are clamped to block arbitrary-package
78
+ installation, and a committed NUL byte in `config.ts` (which rendered the file
79
+ binary and invisible to gitleaks/SAST/grep) was removed.
80
+
81
+ ### Changed
82
+
83
+ - Under `approvalStrategy:"mcp_managed"`, every provider now defaults to an
84
+ accept-edits-level mode (auto-accept file edits, dangerous tools still gated)
85
+ instead of full auto-approve: Claude `--permission-mode acceptEdits`, Grok
86
+ `--permission-mode acceptEdits`, Mistral `--agent accept-edits`, and Gemini
87
+ prompted `default` (the `agy` CLI has no accept-edits rung, so without the
88
+ opt-in Gemini cannot auto-approve mutating tools under `mcp_managed`). Each
89
+ escalates to its full auto-approve mode only when the operator sets
90
+ `LLM_GATEWAY_APPROVAL_ALLOW_BYPASS`. The `legacy` strategy is unchanged.
91
+
7
92
  ## [2.8.0] - 2026-06-14: HTTP workspace gating and ACP transport
8
93
 
9
94
  ### Added
package/README.md CHANGED
@@ -1160,6 +1160,24 @@ await callTool("session_delete", {
1160
1160
  ```bash
1161
1161
  LLM_GATEWAY_APPROVAL_POLICY=strict node dist/index.js
1162
1162
  ```
1163
+ - `LLM_GATEWAY_APPROVAL_ALLOW_BYPASS`: Under `approvalStrategy:"mcp_managed"`, a full permission / sandbox bypass request (e.g. `dangerouslyBypassApprovalsAndSandbox`, `dangerouslySkipPermissions`) is **denied by default** regardless of approval score, **and** `mcp_managed` no longer force-bypasses any provider — each defaults to an auto-accept-edits-level mode (auto-accept file edits, still gate Bash and other dangerous tools) instead of full auto-approve:
1164
+ - **Claude** → `--permission-mode acceptEdits` (was `bypassPermissions`)
1165
+ - **Grok** → `--permission-mode acceptEdits` (was `--always-approve`)
1166
+ - **Mistral (Vibe)** → `--agent accept-edits` (was `--agent auto-approve`)
1167
+ - **Gemini (Antigravity)** → `default` / prompted, i.e. **no** `--dangerously-skip-permissions` (the `agy` CLI has no accept-edits middle rung, so the safe default is prompted execution; without the opt-in, Gemini cannot auto-approve mutating tools under `mcp_managed`)
1168
+
1169
+ Set to `1`/`true` to let the operator opt back in: this permits bypass requests through the approval gate **and** restores each provider's full auto-approve mode under `mcp_managed` (Claude `bypassPermissions`, Grok `--always-approve`, Mistral `--agent auto-approve`, Gemini `--dangerously-skip-permissions`). Sandboxed auto modes (e.g. codex `--sandbox workspace-write`) are unaffected.
1170
+ ```bash
1171
+ LLM_GATEWAY_APPROVAL_ALLOW_BYPASS=1 node dist/index.js
1172
+ ```
1173
+ - `LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER`: Name of an HTTP header carrying the authenticated user identity asserted by a **trusted front door** (any identity-aware reverse proxy / IdP). When set, the gateway adopts that header value as the request's ownership principal — but **only** for requests authenticated with the gateway's own static bearer token (i.e. the trusted upstream proxy), never from an arbitrary remote client. Off by default; IdP-agnostic. Lets a proxy-fronted multi-user deployment carry per-user identity into the gateway.
1174
+ ```bash
1175
+ LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER=x-gateway-principal node dist/index.js
1176
+ ```
1177
+ - `LLM_GATEWAY_OAUTH_REQUIRE_CONSENT` / `LLM_GATEWAY_OAUTH_CONSENT_SECRET`: Opt-in human-consent gate for the built-in OAuth server. When enabled (`REQUIRE_CONSENT=1`, or implied by setting `CONSENT_SECRET`), `/oauth/authorize` renders an operator approval page (CSRF-protected) and issues an authorization code **only** after the dedicated consent password is entered — instead of auto-issuing. `CONSENT_SECRET` is the plaintext password (hashed in memory; or persist a `consent_secret_hash` in `[http.oauth]`). Off by default; remote OAuth refuses to enable consent without a secret to verify.
1178
+ ```bash
1179
+ LLM_GATEWAY_OAUTH_REQUIRE_CONSENT=1 LLM_GATEWAY_OAUTH_CONSENT_SECRET='choose-a-strong-code' node dist/index.js
1180
+ ```
1163
1181
  - `LLM_GATEWAY_CONFIG`: Path to the gateway TOML config (default: `~/.llm-cli-gateway/config.toml`). See **Persistence configuration** above for the `[persistence]` schema.
1164
1182
  - `LLM_GATEWAY_LOGS_DB`: **Deprecated** — overrides `[persistence].path` and selects `backend = "sqlite"` (or `backend = "none"` when set to `none`). Emits a deprecation warning at startup; migrate to `config.toml`.
1165
1183
  ```bash
@@ -1168,6 +1186,11 @@ await callTool("session_delete", {
1168
1186
  # Disable durable persistence (also disables *_request_async tools)
1169
1187
  LLM_GATEWAY_LOGS_DB=none node dist/index.js
1170
1188
  ```
1189
+ - `LLM_GATEWAY_REDACT_LOGGED_SECRETS`: Redact recognisable secrets (provider/cloud/VCS keys, bearer tokens, JWTs, PEM private keys, `key=value` secret assignments) from the prompt/system/response copies written to the flight-recorder log. **Enabled by default**; set to `0`/`false`/`off`/`no` to store content verbatim. Only the audit log is affected — live sync responses and async `llm_job_result` output are never altered.
1190
+ ```bash
1191
+ # Opt out of flight-recorder secret redaction
1192
+ LLM_GATEWAY_REDACT_LOGGED_SECRETS=0 node dist/index.js
1193
+ ```
1171
1194
 
1172
1195
  ### CLI-Specific Settings
1173
1196
 
@@ -34,6 +34,7 @@ export interface ApprovalRecord {
34
34
  metadata?: Record<string, unknown>;
35
35
  reviewIntegrity?: ReviewIntegrityResult;
36
36
  }
37
+ export declare function bypassAllowedByOperator(env?: NodeJS.ProcessEnv): boolean;
37
38
  export declare class ApprovalManager {
38
39
  private logger;
39
40
  private readonly logPath;
@@ -14,6 +14,10 @@ function parsePolicy(policy) {
14
14
  }
15
15
  return "balanced";
16
16
  }
17
+ export function bypassAllowedByOperator(env = process.env) {
18
+ const raw = (env.LLM_GATEWAY_APPROVAL_ALLOW_BYPASS || "").trim().toLowerCase();
19
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
20
+ }
17
21
  function promptPreview(prompt) {
18
22
  if (process.env.APPROVAL_LOG_PROMPTS === "1") {
19
23
  return prompt.replace(/\s+/g, " ").trim().slice(0, 280);
@@ -106,8 +110,17 @@ export class ApprovalManager {
106
110
  reasons.push(`Review integrity: ${violation.detail}`);
107
111
  }
108
112
  }
113
+ const bypassDeniedByDefault = request.bypassRequested && !bypassAllowedByOperator();
114
+ if (bypassDeniedByDefault) {
115
+ reasons.push("Full permission/sandbox bypass denied by default under MCP-managed approval " +
116
+ "(set LLM_GATEWAY_APPROVAL_ALLOW_BYPASS=1 to permit)");
117
+ }
109
118
  const threshold = policy === "strict" ? 2 : policy === "balanced" ? 5 : 7;
110
- const status = score <= threshold ? "approved" : "denied";
119
+ const status = bypassDeniedByDefault
120
+ ? "denied"
121
+ : score <= threshold
122
+ ? "approved"
123
+ : "denied";
111
124
  const record = {
112
125
  id: randomUUID(),
113
126
  ts: new Date().toISOString(),
@@ -81,6 +81,7 @@ export declare class AsyncJobManager {
81
81
  private maybeFlushOutput;
82
82
  private persistComplete;
83
83
  private hydrateFromStore;
84
+ getJobOwner(jobId: string): string | null | undefined;
84
85
  startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean, env?: Record<string, string>, onComplete?: () => void, flightRecorderEntry?: AsyncJobFlightRecorderEntry, extractUsage?: AsyncJobUsageExtractor, writeFlightStart?: boolean, stdin?: string): AsyncJobSnapshot;
85
86
  startJobWithDedup(cli: LlmCli, args: string[], correlationId: string, opts?: StartJobOptions): StartJobOutcome;
86
87
  getJobSnapshot(jobId: string): AsyncJobSnapshot | null;
@@ -5,6 +5,7 @@ import { ProcessMonitor } from "./process-monitor.js";
5
5
  import { computeRequestKey } from "./job-store.js";
6
6
  import { NoopFlightRecorder, } from "./flight-recorder.js";
7
7
  import { codexFrResponse } from "./codex-json-parser.js";
8
+ import { getRequestContext, resolveOwnerPrincipal } from "./request-context.js";
8
9
  const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
9
10
  const JOB_TTL_MS = 60 * 60 * 1000;
10
11
  const EVICTION_INTERVAL_MS = 5 * 60 * 1000;
@@ -406,12 +407,19 @@ export class AsyncJobManager {
406
407
  exited: row.status !== "running",
407
408
  metricsRecorded: true,
408
409
  outputFormat: row.outputFormat ?? undefined,
410
+ ownerPrincipal: row.ownerPrincipal,
409
411
  outputDirty: false,
410
412
  lastOutputFlushAt: Date.now(),
411
413
  };
412
414
  this.jobs.set(jobId, reconstituted);
413
415
  return reconstituted;
414
416
  }
417
+ getJobOwner(jobId) {
418
+ let job = this.jobs.get(jobId);
419
+ if (!job)
420
+ job = this.hydrateFromStore(jobId) ?? undefined;
421
+ return job?.ownerPrincipal;
422
+ }
415
423
  startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin) {
416
424
  return this.startJobWithDedup(cli, args, correlationId, {
417
425
  cwd,
@@ -489,9 +497,11 @@ export class AsyncJobManager {
489
497
  if (child.pid)
490
498
  unregisterProcessGroup(child.pid);
491
499
  };
500
+ const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
492
501
  const job = {
493
502
  id,
494
503
  cli,
504
+ ownerPrincipal,
495
505
  args: [...args],
496
506
  requestKey,
497
507
  correlationId,
@@ -528,6 +538,7 @@ export class AsyncJobManager {
528
538
  outputFormat,
529
539
  startedAt,
530
540
  pid: child.pid ?? null,
541
+ ownerPrincipal,
531
542
  }));
532
543
  if (writeFlightStart && flightRecorderEntry) {
533
544
  try {
package/dist/auth.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface RemoteOAuthConfig {
32
32
  registrationPolicy: OAuthRegistrationPolicy;
33
33
  allowPublicClients: boolean;
34
34
  tokenTtlSeconds: number;
35
+ requireConsent: boolean;
36
+ consentSecretHash: string | null;
35
37
  clients: RemoteOAuthClientConfig[];
36
38
  sharedSecret: RemoteOAuthSharedSecretConfig | null;
37
39
  sources: {
@@ -53,6 +55,8 @@ export declare function issueOAuthAccessToken(args: {
53
55
  scope: string;
54
56
  };
55
57
  export declare function authorizeBearerRequest(req: IncomingMessage, token?: string | null): AuthResult;
58
+ export declare function trustedPrincipalHeaderName(env?: NodeJS.ProcessEnv): string | null;
59
+ export declare function resolveTrustedPrincipal(req: IncomingMessage, auth: AuthResult, env?: NodeJS.ProcessEnv): string | undefined;
56
60
  export declare function writeAuthFailure(res: ServerResponse, result: AuthResult, options?: {
57
61
  resourceMetadataUrl?: string;
58
62
  }): void;
package/dist/auth.js CHANGED
@@ -79,6 +79,22 @@ export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
79
79
  }
80
80
  return { ok: false, status: 401, message: "Unauthorized" };
81
81
  }
82
+ const TRUSTED_PRINCIPAL_PATTERN = /^[A-Za-z0-9._@:+=/-]{1,256}$/;
83
+ export function trustedPrincipalHeaderName(env = process.env) {
84
+ const raw = (env.LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER || "").trim().toLowerCase();
85
+ return raw || null;
86
+ }
87
+ export function resolveTrustedPrincipal(req, auth, env = process.env) {
88
+ const headerName = trustedPrincipalHeaderName(env);
89
+ if (!headerName || auth.kind !== "gateway_bearer")
90
+ return undefined;
91
+ const raw = req.headers[headerName];
92
+ const value = Array.isArray(raw) ? raw[0] : raw;
93
+ if (!value)
94
+ return undefined;
95
+ const trimmed = value.trim();
96
+ return TRUSTED_PRINCIPAL_PATTERN.test(trimmed) ? trimmed : undefined;
97
+ }
82
98
  export function writeAuthFailure(res, result, options = {}) {
83
99
  const status = result.status ?? 401;
84
100
  let wwwAuthenticate = 'Bearer realm="llm-cli-gateway"';
@@ -90,6 +90,7 @@ export interface PersistedRequestRecord {
90
90
  response: string | null;
91
91
  prompt?: string;
92
92
  thinkingBlocks: string[] | null;
93
+ ownerPrincipal: string | null;
93
94
  }
94
95
  export interface ReadPersistedRequestOptions {
95
96
  maxChars?: number;
@@ -263,7 +263,7 @@ export function readPersistedRequest(db, correlationId, opts = {}) {
263
263
  const maxChars = opts.maxChars ?? PERSISTED_REQUEST_DEFAULT_MAX_CHARS;
264
264
  const rows = db.queryRequests(`SELECT r.id, r.cli, r.model, r.prompt, r.response, r.session_id,
265
265
  r.datetime_utc, r.duration_ms, r.input_tokens, r.output_tokens,
266
- r.cache_read_tokens, r.cache_creation_tokens,
266
+ r.cache_read_tokens, r.cache_creation_tokens, r.owner_principal,
267
267
  m.retry_count, m.circuit_breaker_state, m.cost_usd,
268
268
  m.exit_code, m.error_message, m.async_job_id, m.status,
269
269
  m.thinking_blocks
@@ -301,6 +301,7 @@ export function readPersistedRequest(db, correlationId, opts = {}) {
301
301
  responseTruncated,
302
302
  response,
303
303
  thinkingBlocks: parseThinkingBlocks(row.thinking_blocks),
304
+ ownerPrincipal: row.owner_principal,
304
305
  };
305
306
  if (opts.includePrompt) {
306
307
  record.prompt = row.prompt ?? "";
@@ -237,10 +237,13 @@ export async function runCliUpgrade(params) {
237
237
  exitCode: result.code,
238
238
  };
239
239
  }
240
+ const UPGRADE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
240
241
  function normalizeTarget(target) {
241
242
  const normalized = target.trim();
242
- if (!normalized || normalized.startsWith("-") || /[\u0000-\u001f\u007f\s]/.test(normalized)) {
243
- throw new Error("Upgrade target must be a non-empty package tag or version without whitespace and cannot start with '-'");
243
+ if (!UPGRADE_TARGET_PATTERN.test(normalized)) {
244
+ throw new Error("Upgrade target must be a bare version or dist-tag (letters, digits, '.', '_', '-'; " +
245
+ "1-64 chars; must start alphanumeric). Package specifiers, aliases, URLs, and paths " +
246
+ "(containing ':', '/', or '@') are not allowed.");
244
247
  }
245
248
  return normalized;
246
249
  }
package/dist/config.js CHANGED
@@ -353,7 +353,7 @@ export const DEFAULT_ACP_PROCESS_IDLE_TIMEOUT_MS = 600000;
353
353
  export const DEFAULT_ACP_INITIALIZE_TIMEOUT_MS = 10000;
354
354
  export const DEFAULT_ACP_SESSION_NEW_TIMEOUT_MS = 10000;
355
355
  export const DEFAULT_ACP_PROMPT_TIMEOUT_MS = 600000;
356
- const SHELL_METACHARACTERS = /[\s|&;<>(){}$`"'\\*?[\]~#!]/;
356
+ const SHELL_METACHARACTERS = /[\s|&;<>(){}$`"'\\*?[\]~#!\0]/;
357
357
  function isSafeExecutable(value) {
358
358
  if (value.length === 0)
359
359
  return false;
@@ -492,6 +492,8 @@ const OAuthConfigSchema = z
492
492
  registration_policy: OAuthRegistrationPolicySchema.default("static_clients"),
493
493
  allow_public_clients: z.boolean().default(false),
494
494
  token_ttl_seconds: z.number().int().positive().default(3600),
495
+ require_consent: z.boolean().default(false),
496
+ consent_secret_hash: z.string().optional(),
495
497
  clients: z.array(OAuthClientSchema).default([]),
496
498
  shared_secret: OAuthSharedSecretSchema.optional(),
497
499
  })
@@ -505,6 +507,8 @@ function disabledOAuthConfig(sourcePath = null, envOverrides = []) {
505
507
  registrationPolicy: "static_clients",
506
508
  allowPublicClients: false,
507
509
  tokenTtlSeconds: 3600,
510
+ requireConsent: false,
511
+ consentSecretHash: null,
508
512
  clients: [],
509
513
  sharedSecret: null,
510
514
  sources: { configFile: sourcePath, envOverrides },
@@ -536,6 +540,15 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
536
540
  ? "LLM_GATEWAY_OAUTH_REGISTRATION_SECRET"
537
541
  : "LLM_GATEWAY_OAUTH_SHARED_SECRET");
538
542
  }
543
+ if (env.LLM_GATEWAY_OAUTH_REQUIRE_CONSENT !== undefined) {
544
+ merged.require_consent = env.LLM_GATEWAY_OAUTH_REQUIRE_CONSENT === "1";
545
+ envOverrides.push("LLM_GATEWAY_OAUTH_REQUIRE_CONSENT");
546
+ }
547
+ if (env.LLM_GATEWAY_OAUTH_CONSENT_SECRET) {
548
+ merged.consent_secret_hash = hashSecret(env.LLM_GATEWAY_OAUTH_CONSENT_SECRET);
549
+ merged.require_consent = merged.require_consent ?? true;
550
+ envOverrides.push("LLM_GATEWAY_OAUTH_CONSENT_SECRET");
551
+ }
539
552
  const parsed = OAuthConfigSchema.safeParse(merged);
540
553
  if (!parsed.success) {
541
554
  logWarn(logger, "Invalid [http.oauth] config; remote OAuth disabled", {
@@ -578,6 +591,12 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
578
591
  if (data.registration_policy === "open_dev" && env.LLM_GATEWAY_OAUTH_OPEN_DEV !== "1") {
579
592
  logWarn(logger, "[http.oauth].registration_policy='open_dev' is intended for localhost/dev only");
580
593
  }
594
+ if (data.require_consent) {
595
+ if (!data.consent_secret_hash || !isSecretHash(data.consent_secret_hash)) {
596
+ logWarn(logger, "[http.oauth].require_consent is set but consent_secret_hash is missing/invalid; remote OAuth disabled");
597
+ return disabledOAuthConfig(sourcePath, envOverrides);
598
+ }
599
+ }
581
600
  return {
582
601
  enabled: data.enabled,
583
602
  issuer: data.issuer,
@@ -586,6 +605,8 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
586
605
  registrationPolicy: data.registration_policy,
587
606
  allowPublicClients: data.allow_public_clients,
588
607
  tokenTtlSeconds: data.token_ttl_seconds,
608
+ requireConsent: data.require_consent,
609
+ consentSecretHash: data.consent_secret_hash ?? null,
589
610
  clients: data.clients.map(client => ({
590
611
  clientId: client.client_id,
591
612
  clientSecretHash: client.client_secret_hash ?? null,
@@ -11,6 +11,7 @@ export interface FlightLogStart {
11
11
  stablePrefixTokens?: number;
12
12
  cacheControlBlocks?: number;
13
13
  cacheControlTtlSeconds?: number;
14
+ ownerPrincipal?: string | null;
14
15
  }
15
16
  export interface FlightLogResult {
16
17
  response: string;
@@ -39,11 +40,16 @@ export declare class FlightRecorder {
39
40
  private readOnlyDb;
40
41
  private closed;
41
42
  private readonly dbPath;
43
+ private readonly redactEnabled;
42
44
  private insertStartTxn;
43
45
  private updateCompleteTxn;
44
- constructor(dbPath: string);
46
+ constructor(dbPath: string, options?: {
47
+ redactSecrets?: boolean;
48
+ });
45
49
  logStart(entry: FlightLogStart): void;
46
50
  logComplete(correlationId: string, result: FlightLogResult): void;
51
+ private redactStart;
52
+ private redactResult;
47
53
  queryRequests<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T[];
48
54
  flush(): void;
49
55
  close(): void;
@@ -2,6 +2,8 @@ import { chmodSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { openDatabase, openReadOnly } from "./sqlite-driver.js";
5
+ import { redactSecrets, isRedactionEnabled } from "./secret-redaction.js";
6
+ import { getRequestContext, resolveOwnerPrincipal } from "./request-context.js";
5
7
  const MAX_THINKING_BYTES = 1_000_000;
6
8
  function ensureRequestsCacheColumns(db) {
7
9
  const rows = db.prepare("PRAGMA table_info(requests)").all();
@@ -13,6 +15,13 @@ function ensureRequestsCacheColumns(db) {
13
15
  db.exec("ALTER TABLE requests ADD COLUMN cache_creation_tokens INTEGER");
14
16
  }
15
17
  }
18
+ function ensureRequestsOwnerColumn(db) {
19
+ const rows = db.prepare("PRAGMA table_info(requests)").all();
20
+ const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
21
+ if (!names.has("owner_principal")) {
22
+ db.exec("ALTER TABLE requests ADD COLUMN owner_principal TEXT");
23
+ }
24
+ }
16
25
  function ensureStablePrefixColumns(db) {
17
26
  const rows = db.prepare("PRAGMA table_info(requests)").all();
18
27
  const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
@@ -87,10 +96,12 @@ export class FlightRecorder {
87
96
  readOnlyDb = null;
88
97
  closed = false;
89
98
  dbPath;
99
+ redactEnabled;
90
100
  insertStartTxn;
91
101
  updateCompleteTxn;
92
- constructor(dbPath) {
102
+ constructor(dbPath, options = {}) {
93
103
  this.dbPath = dbPath;
104
+ this.redactEnabled = options.redactSecrets ?? isRedactionEnabled();
94
105
  this.db = openDatabase(dbPath);
95
106
  this.db.exec("PRAGMA journal_mode = WAL");
96
107
  this.db.exec("PRAGMA foreign_keys = ON");
@@ -113,7 +124,8 @@ export class FlightRecorder {
113
124
  input_tokens INTEGER,
114
125
  output_tokens INTEGER,
115
126
  cache_read_tokens INTEGER,
116
- cache_creation_tokens INTEGER
127
+ cache_creation_tokens INTEGER,
128
+ owner_principal TEXT
117
129
  );
118
130
 
119
131
  CREATE TABLE IF NOT EXISTS gateway_metadata (
@@ -155,6 +167,10 @@ export class FlightRecorder {
155
167
  this.db
156
168
  .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(5, ?)")
157
169
  .run(new Date().toISOString());
170
+ ensureRequestsOwnerColumn(this.db);
171
+ this.db
172
+ .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(6, ?)")
173
+ .run(new Date().toISOString());
158
174
  if (process.platform !== "win32") {
159
175
  try {
160
176
  chmodSync(dbPath, 0o600);
@@ -165,10 +181,10 @@ export class FlightRecorder {
165
181
  const insertRequest = this.db.prepare(`
166
182
  INSERT INTO requests (id, cli, model, prompt, system, session_id, datetime_utc,
167
183
  stable_prefix_hash, stable_prefix_tokens,
168
- cache_control_blocks, cache_control_ttl_seconds)
184
+ cache_control_blocks, cache_control_ttl_seconds, owner_principal)
169
185
  VALUES (@id, @cli, @model, @prompt, @system, @session_id, @datetime_utc,
170
186
  @stable_prefix_hash, @stable_prefix_tokens,
171
- @cache_control_blocks, @cache_control_ttl_seconds)
187
+ @cache_control_blocks, @cache_control_ttl_seconds, @owner_principal)
172
188
  `);
173
189
  const insertMetadata = this.db.prepare(`
174
190
  INSERT INTO gateway_metadata (request_id, async_job_id, status)
@@ -187,6 +203,7 @@ export class FlightRecorder {
187
203
  stable_prefix_tokens: entry.stablePrefixTokens ?? null,
188
204
  cache_control_blocks: entry.cacheControlBlocks ?? null,
189
205
  cache_control_ttl_seconds: entry.cacheControlTtlSeconds ?? null,
206
+ owner_principal: entry.ownerPrincipal ?? resolveOwnerPrincipal(getRequestContext()),
190
207
  });
191
208
  insertMetadata.run({
192
209
  request_id: entry.correlationId,
@@ -244,10 +261,20 @@ export class FlightRecorder {
244
261
  });
245
262
  }
246
263
  logStart(entry) {
247
- this.insertStartTxn(entry);
264
+ this.insertStartTxn(this.redactEnabled ? this.redactStart(entry) : entry);
248
265
  }
249
266
  logComplete(correlationId, result) {
250
- this.updateCompleteTxn(correlationId, result);
267
+ this.updateCompleteTxn(correlationId, this.redactEnabled ? this.redactResult(result) : result);
268
+ }
269
+ redactStart(entry) {
270
+ return {
271
+ ...entry,
272
+ prompt: redactSecrets(entry.prompt),
273
+ system: entry.system ? redactSecrets(entry.system) : entry.system,
274
+ };
275
+ }
276
+ redactResult(result) {
277
+ return { ...result, response: redactSecrets(result.response) };
251
278
  }
252
279
  queryRequests(sql, ...params) {
253
280
  if (this.closed) {
@@ -2,10 +2,11 @@ import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
- import { authorizeBearerRequest, getRequiredBearerToken, writeAuthFailure } from "./auth.js";
5
+ import { authorizeBearerRequest, getRequiredBearerToken, resolveTrustedPrincipal, writeAuthFailure, } from "./auth.js";
6
6
  import { loadRemoteOAuthConfig } from "./config.js";
7
7
  import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
8
8
  import { runWithRequestContext } from "./request-context.js";
9
+ import { readCappedRawBody, maxHttpBodyBytes } from "./request-limits.js";
9
10
  const noopLogger = {
10
11
  info: (..._args) => { },
11
12
  error: (..._args) => { },
@@ -14,22 +15,8 @@ const noopLogger = {
14
15
  function firstHeader(value) {
15
16
  return Array.isArray(value) ? value[0] : value;
16
17
  }
17
- function readRawBody(req) {
18
- return new Promise((resolve, reject) => {
19
- const chunks = [];
20
- req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
21
- req.on("error", reject);
22
- req.on("end", () => {
23
- if (chunks.length === 0) {
24
- resolve("");
25
- return;
26
- }
27
- resolve(Buffer.concat(chunks).toString("utf8"));
28
- });
29
- });
30
- }
31
18
  async function readBody(req) {
32
- const raw = await readRawBody(req);
19
+ const raw = await readCappedRawBody(req, maxHttpBodyBytes());
33
20
  if (!raw)
34
21
  return undefined;
35
22
  try {
@@ -94,6 +81,13 @@ export async function startHttpGateway(options) {
94
81
  const sessions = new Map();
95
82
  const token = getRequiredBearerToken();
96
83
  const oauthConfig = loadRemoteOAuthConfig(logger);
84
+ if (oauthConfig.enabled &&
85
+ (oauthConfig.allowPublicClients || oauthConfig.registrationPolicy === "open_dev") &&
86
+ !isLocalHost(host)) {
87
+ throw new Error(`Refusing to start: remote OAuth with ${oauthConfig.allowPublicClients ? "public clients" : "open_dev registration"} is exposed on a non-loopback bind (host=${host}). Bind LLM_GATEWAY_HTTP_HOST to 127.0.0.1 ` +
88
+ `and front the gateway with an authenticating proxy, or switch to ` +
89
+ `registration_policy=static_clients with confidential client secrets.`);
90
+ }
97
91
  const oauthServer = oauthConfig.enabled
98
92
  ? new OAuthServer({ protectedPath: path, config: oauthConfig, logger })
99
93
  : null;
@@ -162,11 +156,13 @@ export async function startHttpGateway(options) {
162
156
  writeAuthFailure(res, auth, resourceMetadataUrl ? { resourceMetadataUrl } : {});
163
157
  return;
164
158
  }
159
+ const trustedPrincipal = resolveTrustedPrincipal(req, auth);
165
160
  requestContext = {
166
161
  transport: "http",
167
162
  authKind: auth.kind,
168
163
  authScopes: auth.scopes ?? [],
169
164
  authClientId: auth.clientId,
165
+ authPrincipal: trustedPrincipal ?? auth.clientId,
170
166
  };
171
167
  }
172
168
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
@@ -214,7 +210,13 @@ export async function startHttpGateway(options) {
214
210
  catch (error) {
215
211
  logger.error("HTTP transport request failed", error);
216
212
  if (!res.headersSent) {
217
- jsonError(res, 500, "Internal server error");
213
+ const statusCode = error?.statusCode;
214
+ if (statusCode === 413) {
215
+ jsonError(res, 413, "Payload too large");
216
+ }
217
+ else {
218
+ jsonError(res, 500, "Internal server error");
219
+ }
218
220
  }
219
221
  else {
220
222
  res.end();