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/CHANGELOG.md +45 -0
- package/README.md +23 -0
- package/dist/approval-manager.d.ts +1 -0
- package/dist/approval-manager.js +14 -1
- package/dist/async-job-manager.d.ts +1 -0
- package/dist/async-job-manager.js +11 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +16 -0
- package/dist/cache-stats.d.ts +1 -0
- package/dist/cache-stats.js +2 -1
- package/dist/cli-updater.js +5 -2
- package/dist/config.js +22 -1
- package/dist/flight-recorder.d.ts +7 -1
- package/dist/flight-recorder.js +33 -6
- package/dist/http-transport.js +19 -17
- package/dist/index.js +86 -19
- package/dist/job-store.d.ts +4 -0
- package/dist/job-store.js +16 -4
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +90 -8
- package/dist/request-context.d.ts +3 -0
- package/dist/request-context.js +16 -0
- package/dist/request-limits.d.ts +8 -0
- package/dist/request-limits.js +49 -0
- package/dist/secret-redaction.d.ts +3 -0
- package/dist/secret-redaction.js +53 -0
- package/dist/session-manager-pg.js +8 -5
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.js +2 -0
- package/migrations/004_session_owner_principal.sql +10 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [2.9.0] - 2026-06-14: MCP-surface red-team remediation
|
|
8
|
+
|
|
9
|
+
This release remediates all 17 findings of a multi-LLM red-team of the gateway's
|
|
10
|
+
external and internal MCP surface. Every change is material only in the opt-in
|
|
11
|
+
remote/OAuth/multi-tenant modes or under `approvalStrategy:"mcp_managed"`; the
|
|
12
|
+
default local-stdio path is unaffected. Provider CLI release targets are
|
|
13
|
+
unchanged from 2.8.0 (see `docs/upstream/release-targets.md`).
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- Per-principal isolation (F3): `session_*`, `llm_job_*`, and
|
|
18
|
+
`llm_request_result` now resolve a calling owner principal and enforce
|
|
19
|
+
own-or-not-found access, so one OAuth client can no longer read or mutate
|
|
20
|
+
another client's sessions, jobs, or persisted request results. Owner columns
|
|
21
|
+
are additive and nullable; legacy unowned rows remain accessible to local
|
|
22
|
+
callers. The shared static bearer is one principal by design.
|
|
23
|
+
- Built-in OAuth server hardening (F14): the authorization-code flow gains an
|
|
24
|
+
opt-in human-consent gate (dedicated consent password, default off) and a
|
|
25
|
+
trusted-principal-header seam so a proxy front door can attribute requests
|
|
26
|
+
without the gateway shipping any identity-provider configuration.
|
|
27
|
+
- Approval-gate hard boundary (F15): under `approvalStrategy:"mcp_managed"` a
|
|
28
|
+
full permission/sandbox bypass request is now denied by default regardless of
|
|
29
|
+
heuristic score, and the managed strategy no longer force-sets each provider's
|
|
30
|
+
most-permissive mode. See "Changed" for the per-provider defaults.
|
|
31
|
+
- Secret redaction (F4): prompts and responses are redacted before they are
|
|
32
|
+
written to the flight-recorder database.
|
|
33
|
+
- Remote-exposure fail-closed (F17): a remotely-exposed open OAuth configuration
|
|
34
|
+
refuses to start, and the `Host` header is no longer trusted for loopback
|
|
35
|
+
determination.
|
|
36
|
+
- MCP surface hardening (F1/F2/F10): request bodies are capped to prevent a
|
|
37
|
+
memory DoS, `cli_upgrade` targets are clamped to block arbitrary-package
|
|
38
|
+
installation, and a committed NUL byte in `config.ts` (which rendered the file
|
|
39
|
+
binary and invisible to gitleaks/SAST/grep) was removed.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Under `approvalStrategy:"mcp_managed"`, every provider now defaults to an
|
|
44
|
+
accept-edits-level mode (auto-accept file edits, dangerous tools still gated)
|
|
45
|
+
instead of full auto-approve: Claude `--permission-mode acceptEdits`, Grok
|
|
46
|
+
`--permission-mode acceptEdits`, Mistral `--agent accept-edits`, and Gemini
|
|
47
|
+
prompted `default` (the `agy` CLI has no accept-edits rung, so without the
|
|
48
|
+
opt-in Gemini cannot auto-approve mutating tools under `mcp_managed`). Each
|
|
49
|
+
escalates to its full auto-approve mode only when the operator sets
|
|
50
|
+
`LLM_GATEWAY_APPROVAL_ALLOW_BYPASS`. The `legacy` strategy is unchanged.
|
|
51
|
+
|
|
7
52
|
## [2.8.0] - 2026-06-14: HTTP workspace gating and ACP transport
|
|
8
53
|
|
|
9
54
|
### 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;
|
package/dist/approval-manager.js
CHANGED
|
@@ -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 =
|
|
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"';
|
package/dist/cache-stats.d.ts
CHANGED
package/dist/cache-stats.js
CHANGED
|
@@ -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 ?? "";
|
package/dist/cli-updater.js
CHANGED
|
@@ -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 (!
|
|
243
|
-
throw new Error("Upgrade target must be a
|
|
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;
|
package/dist/flight-recorder.js
CHANGED
|
@@ -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) {
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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();
|