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/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
|
-
|
|
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"
|
|
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
|
|
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 (
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5745
|
-
const
|
|
5746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
package/dist/job-store.d.ts
CHANGED
|
@@ -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
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
|
+
"<": "<",
|
|
59
|
+
">": ">",
|
|
60
|
+
'"': """,
|
|
61
|
+
"'": "'",
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/request-context.js
CHANGED
|
@@ -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,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
|
+
}
|