@provos/ironcurtain 0.7.1 → 0.8.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.
Files changed (108) hide show
  1. package/README.md +29 -100
  2. package/dist/config/first-start.js +4 -1
  3. package/dist/config/first-start.js.map +1 -1
  4. package/dist/config/mcp-servers.json +8 -2
  5. package/dist/config/paths.d.ts +5 -0
  6. package/dist/config/paths.js +8 -0
  7. package/dist/config/paths.js.map +1 -1
  8. package/dist/config/user-config.d.ts +27 -0
  9. package/dist/config/user-config.js +28 -0
  10. package/dist/config/user-config.js.map +1 -1
  11. package/dist/cron/constitution-generator.d.ts +2 -1
  12. package/dist/cron/constitution-generator.js +35 -36
  13. package/dist/cron/constitution-generator.js.map +1 -1
  14. package/dist/daemon/ironcurtain-daemon.js +1 -0
  15. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  16. package/dist/docker/adapters/claude-code.js +32 -5
  17. package/dist/docker/adapters/claude-code.js.map +1 -1
  18. package/dist/docker/agent-adapter.d.ts +40 -0
  19. package/dist/docker/claude-md-seed.d.ts +27 -0
  20. package/dist/docker/claude-md-seed.js +42 -0
  21. package/dist/docker/claude-md-seed.js.map +1 -0
  22. package/dist/docker/docker-agent-session.js +2 -0
  23. package/dist/docker/docker-agent-session.js.map +1 -1
  24. package/dist/docker/docker-infrastructure.d.ts +15 -1
  25. package/dist/docker/docker-infrastructure.js +72 -1
  26. package/dist/docker/docker-infrastructure.js.map +1 -1
  27. package/dist/docker/mitm-proxy.d.ts +15 -0
  28. package/dist/docker/mitm-proxy.js +75 -7
  29. package/dist/docker/mitm-proxy.js.map +1 -1
  30. package/dist/docker/package-types.d.ts +127 -0
  31. package/dist/docker/package-types.js +8 -0
  32. package/dist/docker/package-types.js.map +1 -0
  33. package/dist/docker/package-validator.d.ts +41 -0
  34. package/dist/docker/package-validator.js +90 -0
  35. package/dist/docker/package-validator.js.map +1 -0
  36. package/dist/docker/pty-session.d.ts +15 -0
  37. package/dist/docker/pty-session.js +205 -44
  38. package/dist/docker/pty-session.js.map +1 -1
  39. package/dist/docker/pty-types.d.ts +24 -0
  40. package/dist/docker/pty-types.js +2 -0
  41. package/dist/docker/pty-types.js.map +1 -1
  42. package/dist/docker/registry-proxy.d.ts +142 -0
  43. package/dist/docker/registry-proxy.js +856 -0
  44. package/dist/docker/registry-proxy.js.map +1 -0
  45. package/dist/index.js +10 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/memory/memory-annotations.d.ts +47 -0
  48. package/dist/memory/memory-annotations.js +87 -0
  49. package/dist/memory/memory-annotations.js.map +1 -0
  50. package/dist/memory/memory-prompt.d.ts +12 -0
  51. package/dist/memory/memory-prompt.js +20 -0
  52. package/dist/memory/memory-prompt.js.map +1 -0
  53. package/dist/memory/resolve-memory-path.d.ts +20 -0
  54. package/dist/memory/resolve-memory-path.js +30 -0
  55. package/dist/memory/resolve-memory-path.js.map +1 -0
  56. package/dist/mux/mux-app.js +78 -10
  57. package/dist/mux/mux-app.js.map +1 -1
  58. package/dist/mux/mux-input-handler.d.ts +29 -3
  59. package/dist/mux/mux-input-handler.js +167 -14
  60. package/dist/mux/mux-input-handler.js.map +1 -1
  61. package/dist/mux/mux-renderer.d.ts +3 -1
  62. package/dist/mux/mux-renderer.js +181 -7
  63. package/dist/mux/mux-renderer.js.map +1 -1
  64. package/dist/mux/persona-scanner.d.ts +19 -0
  65. package/dist/mux/persona-scanner.js +53 -0
  66. package/dist/mux/persona-scanner.js.map +1 -0
  67. package/dist/mux/pty-bridge.d.ts +4 -0
  68. package/dist/mux/pty-bridge.js +6 -0
  69. package/dist/mux/pty-bridge.js.map +1 -1
  70. package/dist/mux/session-scanner.d.ts +18 -0
  71. package/dist/mux/session-scanner.js +65 -0
  72. package/dist/mux/session-scanner.js.map +1 -0
  73. package/dist/mux/types.d.ts +13 -1
  74. package/dist/mux/types.js +5 -1
  75. package/dist/mux/types.js.map +1 -1
  76. package/dist/persona/persona-command.js +130 -88
  77. package/dist/persona/persona-command.js.map +1 -1
  78. package/dist/persona/persona-prompt.d.ts +7 -7
  79. package/dist/persona/persona-prompt.js +14 -29
  80. package/dist/persona/persona-prompt.js.map +1 -1
  81. package/dist/persona/resolve.js +5 -2
  82. package/dist/persona/resolve.js.map +1 -1
  83. package/dist/sandbox/index.d.ts +26 -10
  84. package/dist/sandbox/index.js +131 -40
  85. package/dist/sandbox/index.js.map +1 -1
  86. package/dist/session/agent-session.d.ts +2 -1
  87. package/dist/session/agent-session.js +4 -1
  88. package/dist/session/agent-session.js.map +1 -1
  89. package/dist/session/index.d.ts +25 -1
  90. package/dist/session/index.js +57 -8
  91. package/dist/session/index.js.map +1 -1
  92. package/dist/session/prompts.js +8 -8
  93. package/dist/session/types.d.ts +5 -0
  94. package/dist/session/workspace-validation.js +37 -6
  95. package/dist/session/workspace-validation.js.map +1 -1
  96. package/dist/trusted-process/index.js +23 -29
  97. package/dist/trusted-process/index.js.map +1 -1
  98. package/dist/trusted-process/mcp-proxy-server.d.ts +9 -0
  99. package/dist/trusted-process/mcp-proxy-server.js +91 -37
  100. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  101. package/dist/trusted-process/policy-engine.d.ts +4 -1
  102. package/dist/trusted-process/policy-engine.js +15 -1
  103. package/dist/trusted-process/policy-engine.js.map +1 -1
  104. package/docker/Dockerfile.base +22 -0
  105. package/docker/Dockerfile.base.arm64 +44 -4
  106. package/docker/entrypoint-claude-code.sh +9 -1
  107. package/package.json +7 -2
  108. package/src/config/mcp-servers.json +8 -2
@@ -0,0 +1,856 @@
1
+ /**
2
+ * Registry proxy: URL parsing, metadata filtering, and tarball backstop
3
+ * for npm and PyPI package registries.
4
+ *
5
+ * This module provides:
6
+ * - URL parsers that extract PackageIdentity from HTTP request paths
7
+ * - Metadata filtering functions that remove disallowed versions
8
+ * - Tarball backstop logic that blocks direct tarball URL access
9
+ * - Built-in registry configurations for npm and PyPI
10
+ */
11
+ import * as https from 'node:https';
12
+ import { promises as fs } from 'node:fs';
13
+ import { packageCacheKey, canonicalPackageName } from './package-validator.js';
14
+ import * as logger from '../logger.js';
15
+ // ── Built-in registry configs ───────────────────────────────────────
16
+ export const npmRegistry = {
17
+ host: 'registry.npmjs.org',
18
+ displayName: 'npm',
19
+ type: 'npm',
20
+ };
21
+ export const pypiRegistry = {
22
+ host: 'pypi.org',
23
+ displayName: 'PyPI',
24
+ type: 'pypi',
25
+ mirrorHosts: ['files.pythonhosted.org'],
26
+ };
27
+ export const debianRegistry = {
28
+ host: 'deb.debian.org',
29
+ displayName: 'Debian APT',
30
+ type: 'debian',
31
+ mirrorHosts: ['security.debian.org'],
32
+ };
33
+ // ── PyPI sidecar suffixes (PEP 658 metadata, PEP 714 provenance) ────
34
+ const PYPI_SIDECAR_SUFFIXES = ['.metadata', '.provenance'];
35
+ /**
36
+ * Strips known PEP 658/714 sidecar suffixes from a PyPI filename.
37
+ * e.g. "numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl.metadata"
38
+ * -> "numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl"
39
+ * Returns the filename unchanged if no known suffix matches.
40
+ */
41
+ function stripPypiSidecarSuffix(filename) {
42
+ for (const suffix of PYPI_SIDECAR_SUFFIXES) {
43
+ if (filename.endsWith(suffix)) {
44
+ return filename.slice(0, -suffix.length);
45
+ }
46
+ }
47
+ return filename;
48
+ }
49
+ // ── URL Parsers ─────────────────────────────────────────────────────
50
+ /**
51
+ * Parses an npm registry URL path into a PackageIdentity.
52
+ * Returns undefined if the path doesn't match a known pattern.
53
+ *
54
+ * Patterns:
55
+ * GET /@scope/name -> metadata (scope + name)
56
+ * GET /@scope%2fname -> metadata (URL-encoded scope)
57
+ * GET /name -> metadata (unscoped)
58
+ * GET /name/-/name-1.0.0.tgz -> tarball (unscoped)
59
+ * GET /@scope/name/-/name-1.0.0.tgz -> tarball (scoped)
60
+ */
61
+ export function parseNpmUrl(path) {
62
+ // Clean query string
63
+ const cleanPath = path.split('?')[0];
64
+ // Handle URL-encoded scoped packages: /@scope%2fname -> /@scope/name
65
+ let decodedPath;
66
+ try {
67
+ decodedPath = decodeURIComponent(cleanPath);
68
+ }
69
+ catch {
70
+ // Malformed percent-encoding (e.g., %E0%A4) -- fail-closed
71
+ return undefined;
72
+ }
73
+ // Tarball request: contains /-/
74
+ const tarballIndex = decodedPath.indexOf('/-/');
75
+ if (tarballIndex >= 0) {
76
+ const packagePart = decodedPath.substring(1, tarballIndex); // strip leading /
77
+ const filename = decodedPath.substring(tarballIndex + 3); // after /-/
78
+ const version = extractVersionFromNpmTarball(filename, packagePart);
79
+ const parsed = parseNpmPackagePart(packagePart);
80
+ if (!parsed)
81
+ return undefined;
82
+ return { registry: 'npm', ...parsed, version };
83
+ }
84
+ // Metadata request: /@scope/name or /name
85
+ const packagePart = decodedPath.substring(1); // strip leading /
86
+ if (!packagePart || packagePart === '-')
87
+ return undefined;
88
+ const parsed = parseNpmPackagePart(packagePart);
89
+ if (!parsed)
90
+ return undefined;
91
+ return { registry: 'npm', ...parsed };
92
+ }
93
+ /**
94
+ * Parses the package part of an npm URL (without leading /).
95
+ * Returns { name, scope? } or undefined.
96
+ */
97
+ function parseNpmPackagePart(packagePart) {
98
+ if (packagePart.startsWith('@')) {
99
+ const slashIdx = packagePart.indexOf('/');
100
+ if (slashIdx < 0)
101
+ return undefined;
102
+ const scope = packagePart.substring(1, slashIdx);
103
+ const name = packagePart.substring(slashIdx + 1);
104
+ if (!scope || !name)
105
+ return undefined;
106
+ return { name, scope };
107
+ }
108
+ // Ensure we don't parse paths with extra segments as metadata
109
+ // /name/something should not be a metadata request (except /-/ which is handled above)
110
+ if (packagePart.includes('/'))
111
+ return undefined;
112
+ if (!packagePart)
113
+ return undefined;
114
+ return { name: packagePart };
115
+ }
116
+ /**
117
+ * Extracts version from an npm tarball filename.
118
+ * Format: {name}-{version}.tgz
119
+ */
120
+ function extractVersionFromNpmTarball(filename, packagePart) {
121
+ // For scoped packages, filename is just name-version.tgz (no scope prefix)
122
+ const name = packagePart.includes('/') ? (packagePart.split('/').pop() ?? packagePart) : packagePart;
123
+ const prefix = `${name}-`;
124
+ if (!filename.startsWith(prefix) || !filename.endsWith('.tgz'))
125
+ return undefined;
126
+ return filename.slice(prefix.length, -4); // remove prefix and .tgz
127
+ }
128
+ /**
129
+ * Parses a PyPI Simple Repository URL path into a PackageIdentity.
130
+ * Returns undefined if the path doesn't match.
131
+ *
132
+ * Pattern: GET /simple/{name}/
133
+ */
134
+ export function parsePypiSimpleUrl(path) {
135
+ const cleanPath = path.split('?')[0];
136
+ // Match /simple/package-name/ (with or without trailing slash)
137
+ const match = cleanPath.match(/^\/simple\/([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)\/?$/);
138
+ if (!match)
139
+ return undefined;
140
+ return { registry: 'pypi', name: normalizePypiName(match[1]) };
141
+ }
142
+ /**
143
+ * Parses a PyPI tarball URL path into a PackageIdentity.
144
+ * Returns undefined if the path doesn't match.
145
+ *
146
+ * Tarball URLs on files.pythonhosted.org:
147
+ * /packages/.../numpy-1.26.0.tar.gz
148
+ * /packages/.../numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl
149
+ */
150
+ export function parsePypiTarballUrl(path) {
151
+ const cleanPath = path.split('?')[0];
152
+ const filename = cleanPath.split('/').pop();
153
+ if (!filename)
154
+ return undefined;
155
+ return extractPypiPackageFromFilename(filename);
156
+ }
157
+ /**
158
+ * Extracts package name and version from a PyPI distribution filename.
159
+ *
160
+ * Handles both sdist and wheel formats:
161
+ * numpy-1.26.0.tar.gz -> { name: 'numpy', version: '1.26.0' }
162
+ * numpy-1.26.0-cp312-*.whl -> { name: 'numpy', version: '1.26.0' }
163
+ */
164
+ export function extractPypiPackageFromFilename(filename) {
165
+ // Strip known sidecar suffixes (e.g. .whl.metadata -> .whl) before parsing
166
+ const baseFilename = stripPypiSidecarSuffix(filename);
167
+ // Wheel format: {name}-{version}(-{tags}).whl
168
+ // Sdist format: {name}-{version}.tar.gz or {name}-{version}.zip
169
+ // The name-version boundary is the first hyphen followed by a digit
170
+ const match = baseFilename.match(/^([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)-(\d[^-]*?)(?:\.tar\.gz|\.zip|-[a-zA-Z].*\.whl)$/);
171
+ if (!match)
172
+ return undefined;
173
+ return {
174
+ registry: 'pypi',
175
+ name: normalizePypiName(match[1]),
176
+ version: match[2],
177
+ };
178
+ }
179
+ /**
180
+ * Normalizes a PyPI package name to lowercase with hyphens.
181
+ * PEP 503: package names are case-insensitive and treat
182
+ * hyphens, underscores, and dots as equivalent.
183
+ */
184
+ export function normalizePypiName(name) {
185
+ return name.toLowerCase().replace(/[-_.]+/g, '-');
186
+ }
187
+ // ── Debian URL parsing ──────────────────────────────────────────────
188
+ /**
189
+ * Extracts package name and version from a Debian `.deb` filename.
190
+ *
191
+ * Format: {name}_{version}_{arch}.deb
192
+ * Examples:
193
+ * libssl3_3.0.11-1~deb12u2_arm64.deb -> name='libssl3', version='3.0.11-1~deb12u2'
194
+ * gcc-14-base_14.2.0-19_arm64.deb -> name='gcc-14-base', version='14.2.0-19'
195
+ *
196
+ * Epoch versions (e.g., 1:2.3-4) use %3a URL-encoding for the colon.
197
+ */
198
+ export function extractDebianPackageFromFilename(filename) {
199
+ // Must end with .deb
200
+ if (!filename.endsWith('.deb'))
201
+ return undefined;
202
+ // Strip .deb suffix
203
+ const base = filename.slice(0, -4);
204
+ // Split on underscores: name_version_arch
205
+ const firstUnderscore = base.indexOf('_');
206
+ if (firstUnderscore < 1)
207
+ return undefined;
208
+ const lastUnderscore = base.lastIndexOf('_');
209
+ if (lastUnderscore <= firstUnderscore)
210
+ return undefined;
211
+ const name = base.substring(0, firstUnderscore);
212
+ let version = base.substring(firstUnderscore + 1, lastUnderscore);
213
+ // Decode URL-encoded epoch (e.g., 1%3a2.3-4 -> 1:2.3-4)
214
+ try {
215
+ version = decodeURIComponent(version);
216
+ }
217
+ catch {
218
+ // Malformed encoding -- use as-is
219
+ }
220
+ if (!name || !version)
221
+ return undefined;
222
+ return { registry: 'debian', name, version };
223
+ }
224
+ /**
225
+ * Parses a Debian repository URL path into a PackageIdentity.
226
+ * Returns undefined for non-.deb paths (metadata, Release files, etc.).
227
+ *
228
+ * Matches any URL whose last path segment ends with `.deb` — not restricted
229
+ * to `/pool/` paths, since mirrors and security repos may use different layouts.
230
+ * Non-.deb paths (Release, Packages.gz, GPG keys) return undefined for pass-through.
231
+ */
232
+ export function parseDebianPackageUrl(path) {
233
+ const cleanPath = path.split('?')[0];
234
+ const filename = cleanPath.split('/').pop();
235
+ if (!filename || !filename.endsWith('.deb'))
236
+ return undefined;
237
+ return extractDebianPackageFromFilename(filename);
238
+ }
239
+ // ── Request classification ──────────────────────────────────────────
240
+ /** Returns true if the URL path is an npm metadata request (no /-/ segment). */
241
+ export function isNpmMetadataRequest(path) {
242
+ return parseNpmUrl(path) !== undefined && !path.includes('/-/');
243
+ }
244
+ /** Returns true if the URL path is an npm tarball download. */
245
+ export function isNpmTarballRequest(path) {
246
+ return path.includes('/-/') && parseNpmUrl(path)?.version !== undefined;
247
+ }
248
+ /** Returns true if the URL path is a PyPI Simple Repository request. */
249
+ export function isPypiSimpleRequest(path) {
250
+ return parsePypiSimpleUrl(path) !== undefined;
251
+ }
252
+ // ── Metadata filtering ─────────────────────────────────────────────
253
+ /**
254
+ * Filters an npm packument to remove disallowed versions.
255
+ *
256
+ * The filtered packument has:
257
+ * - Disallowed versions removed from `versions` and `time`
258
+ * - `dist-tags.latest` updated to point to the newest remaining version
259
+ * - `created` and `modified` timestamps preserved in `time`
260
+ */
261
+ export function filterNpmPackument(packument, validator, packageName, scope) {
262
+ const denied = [];
263
+ const filteredVersions = {};
264
+ const filteredTime = {};
265
+ // Always keep 'created' and 'modified' timestamps
266
+ if (packument.time?.created)
267
+ filteredTime.created = packument.time.created;
268
+ if (packument.time?.modified)
269
+ filteredTime.modified = packument.time.modified;
270
+ for (const [version, manifest] of Object.entries(packument.versions)) {
271
+ const rawDate = packument.time?.[version] ? new Date(packument.time[version]) : undefined;
272
+ // Treat invalid dates (NaN) as missing — prevents fail-open on garbage timestamps
273
+ const publishedAt = rawDate && Number.isFinite(rawDate.getTime()) ? rawDate : undefined;
274
+ const decision = validator.validate({ registry: 'npm', name: packageName, scope, version }, { publishedAt });
275
+ if (decision.status === 'allow') {
276
+ filteredVersions[version] = manifest;
277
+ if (packument.time?.[version]) {
278
+ filteredTime[version] = packument.time[version];
279
+ }
280
+ }
281
+ else {
282
+ denied.push({ version, reason: decision.reason });
283
+ }
284
+ }
285
+ // Update dist-tags to point to allowed versions only
286
+ const filteredDistTags = {};
287
+ for (const [tag, version] of Object.entries(packument['dist-tags'] ?? {})) {
288
+ if (version in filteredVersions) {
289
+ filteredDistTags[tag] = version;
290
+ }
291
+ }
292
+ // If 'latest' was removed, recalculate as newest remaining version
293
+ if (!filteredDistTags.latest && Object.keys(filteredVersions).length > 0) {
294
+ const newestVersion = Object.keys(filteredTime)
295
+ .filter((k) => k !== 'created' && k !== 'modified')
296
+ .sort((a, b) => new Date(filteredTime[b]).getTime() - new Date(filteredTime[a]).getTime())[0];
297
+ if (newestVersion) {
298
+ filteredDistTags.latest = newestVersion;
299
+ }
300
+ }
301
+ return {
302
+ filtered: {
303
+ ...packument,
304
+ versions: filteredVersions,
305
+ 'dist-tags': filteredDistTags,
306
+ time: filteredTime,
307
+ },
308
+ denied,
309
+ };
310
+ }
311
+ /**
312
+ * Filters a PyPI Simple Repository HTML page to remove disallowed versions.
313
+ *
314
+ * The HTML contains <a> elements with hrefs pointing to tarballs/wheels.
315
+ * Version is extracted from the filename in each href.
316
+ *
317
+ * Returns the filtered HTML, denied version list, and the set of allowed
318
+ * versions (collected during filtering to avoid re-parsing the HTML).
319
+ */
320
+ export function filterPypiIndex(html, validator, packageName, versionTimestamps) {
321
+ const denied = [];
322
+ const allowedVersions = new Set();
323
+ // Track which versions we've already denied (avoid duplicate entries)
324
+ const deniedVersions = new Set();
325
+ // Match <a> tags with href attributes pointing to distribution files
326
+ const filtered = html.replace(/<a\s+[^>]*href="[^"]*"[^>]*>[^<]*<\/a>\s*/gi, (match) => {
327
+ const hrefMatch = match.match(/href="([^"]*)"/);
328
+ if (!hrefMatch)
329
+ return match;
330
+ const href = hrefMatch[1];
331
+ const filename = href.split('/').pop()?.split('#')[0]; // strip fragment
332
+ if (!filename)
333
+ return match;
334
+ const parsed = extractPypiPackageFromFilename(filename);
335
+ if (!parsed || !parsed.version)
336
+ return match;
337
+ const publishedAt = versionTimestamps.get(parsed.version);
338
+ const decision = validator.validate({ registry: 'pypi', name: packageName, version: parsed.version }, publishedAt !== undefined ? { publishedAt } : undefined);
339
+ if (decision.status === 'allow') {
340
+ allowedVersions.add(parsed.version);
341
+ return match;
342
+ }
343
+ // Only add to denied list once per version (multiple files per version)
344
+ if (!deniedVersions.has(parsed.version)) {
345
+ deniedVersions.add(parsed.version);
346
+ denied.push({ version: parsed.version, reason: decision.reason });
347
+ }
348
+ return ''; // Remove the link
349
+ });
350
+ return { filtered, denied, allowedVersions };
351
+ }
352
+ // ── AllowedVersionCache helpers ─────────────────────────────────────
353
+ /** Cache TTL: 1 hour. */
354
+ const CACHE_TTL_MS = 60 * 60 * 1000;
355
+ /**
356
+ * Gets allowed versions from cache, returning undefined on miss or expiry.
357
+ */
358
+ export function getCachedVersions(cache, pkg) {
359
+ const key = packageCacheKey(pkg);
360
+ const entry = cache.get(key);
361
+ if (!entry)
362
+ return undefined;
363
+ if (Date.now() - entry.cachedAt.getTime() > CACHE_TTL_MS) {
364
+ cache.delete(key);
365
+ return undefined;
366
+ }
367
+ return entry.allowedVersions;
368
+ }
369
+ /**
370
+ * Stores allowed versions in the cache.
371
+ */
372
+ export function setCachedVersions(cache, pkg, versions) {
373
+ const key = packageCacheKey(pkg);
374
+ cache.set(key, { allowedVersions: versions, cachedAt: new Date() });
375
+ }
376
+ /**
377
+ * Handles an HTTP request routed to a registry host.
378
+ * Dispatches to metadata filtering or tarball backstop based on URL pattern.
379
+ */
380
+ export async function handleRegistryRequest(registry, clientReq, clientRes, host, port, options) {
381
+ const path = clientReq.url ?? '/';
382
+ switch (registry.type) {
383
+ case 'npm':
384
+ if (isNpmMetadataRequest(path)) {
385
+ await handleNpmMetadata(registry, path, clientReq, clientRes, host, port, options);
386
+ }
387
+ else if (path.includes('/-/') && !path.startsWith('/-/')) {
388
+ // Tarball download: /-/ appears after package name (e.g., /express/-/express-1.0.0.tgz)
389
+ await handleTarballDownload(registry, path, clientRes, host, port, options, 'npm');
390
+ }
391
+ else {
392
+ // npm internal endpoints (/-/ping, /-/v1/security/...) or unknown paths -- pass through
393
+ await forwardUpstream(clientReq, clientRes, host, port);
394
+ }
395
+ break;
396
+ case 'pypi':
397
+ if (isPypiSimpleRequest(path)) {
398
+ await handlePypiSimple(registry, path, clientReq, clientRes, host, port, options);
399
+ }
400
+ else if (registry.mirrorHosts?.includes(host)) {
401
+ // Mirror hosts serve tarballs
402
+ await handleTarballDownload(registry, path, clientRes, host, port, options, 'pypi');
403
+ }
404
+ else {
405
+ // All other pypi.org paths -- pass through
406
+ await forwardUpstream(clientReq, clientRes, host, port);
407
+ }
408
+ break;
409
+ case 'debian':
410
+ await handleDebianRequest(registry, path, clientReq, clientRes, host, port, options);
411
+ break;
412
+ default: {
413
+ const _exhaustive = registry.type;
414
+ throw new Error(`Unknown registry type: ${String(_exhaustive)}`);
415
+ }
416
+ }
417
+ }
418
+ // ── Internal handlers ───────────────────────────────────────────────
419
+ async function handleNpmMetadata(_registry, path, clientReq, clientRes, host, port, options) {
420
+ const pkg = parseNpmUrl(path);
421
+ if (!pkg) {
422
+ await forwardUpstream(clientReq, clientRes, host, port);
423
+ return;
424
+ }
425
+ try {
426
+ const upstream = await fetchUpstreamJson(host, port, path);
427
+ if (!upstream) {
428
+ clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
429
+ clientRes.end('Failed to fetch package metadata from upstream');
430
+ return;
431
+ }
432
+ const { filtered, denied } = filterNpmPackument(upstream, options.validator, pkg.name, pkg.scope);
433
+ // Cache allowed versions
434
+ const allowedVersions = new Set(Object.keys(filtered.versions));
435
+ setCachedVersions(options.cache, pkg, allowedVersions);
436
+ // Audit log denied versions
437
+ for (const d of denied) {
438
+ writeAuditEntry(options.auditLogPath, {
439
+ timestamp: new Date().toISOString(),
440
+ registry: pkg.registry,
441
+ packageName: pkg.name,
442
+ packageScope: pkg.scope,
443
+ packageVersion: d.version,
444
+ decision: 'deny',
445
+ reason: d.reason,
446
+ source: 'metadata-filter',
447
+ requestPath: path,
448
+ });
449
+ }
450
+ // Audit log allowed versions (summary to avoid per-version verbosity)
451
+ if (allowedVersions.size > 0) {
452
+ writeAuditEntry(options.auditLogPath, {
453
+ timestamp: new Date().toISOString(),
454
+ registry: pkg.registry,
455
+ packageName: pkg.name,
456
+ packageScope: pkg.scope,
457
+ packageVersion: `${allowedVersions.size} version(s)`,
458
+ decision: 'allow',
459
+ reason: 'Passed all validation checks',
460
+ source: 'metadata-filter',
461
+ requestPath: path,
462
+ });
463
+ }
464
+ if (denied.length > 0) {
465
+ logger.info(`[registry-proxy] npm ${canonicalPackageName(pkg)}: filtered ${denied.length} version(s), ${allowedVersions.size} allowed`);
466
+ }
467
+ const body = JSON.stringify(filtered);
468
+ clientRes.writeHead(200, {
469
+ 'Content-Type': 'application/json',
470
+ 'Content-Length': Buffer.byteLength(body),
471
+ });
472
+ clientRes.end(body);
473
+ }
474
+ catch (err) {
475
+ logger.info(`[registry-proxy] npm metadata error for ${canonicalPackageName(pkg)}: ${err instanceof Error ? err.message : String(err)}`);
476
+ clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
477
+ clientRes.end('Failed to process package metadata');
478
+ }
479
+ }
480
+ async function handlePypiSimple(_registry, path, clientReq, clientRes, host, port, options) {
481
+ const pkg = parsePypiSimpleUrl(path);
482
+ if (!pkg) {
483
+ await forwardUpstream(clientReq, clientRes, host, port);
484
+ return;
485
+ }
486
+ try {
487
+ // Fetch Simple Repository HTML and version timestamps concurrently
488
+ const [html, versionTimestamps] = await Promise.all([
489
+ fetchUpstreamText(host, port, path, 'text/html'),
490
+ fetchPypiVersionTimestamps(host, port, pkg.name),
491
+ ]);
492
+ if (html === undefined) {
493
+ clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
494
+ clientRes.end('Failed to fetch package index from upstream');
495
+ return;
496
+ }
497
+ const { filtered, denied, allowedVersions } = filterPypiIndex(html, options.validator, pkg.name, versionTimestamps);
498
+ // Cache allowed versions (collected during filtering)
499
+ setCachedVersions(options.cache, pkg, allowedVersions);
500
+ // Audit log denied versions
501
+ for (const d of denied) {
502
+ writeAuditEntry(options.auditLogPath, {
503
+ timestamp: new Date().toISOString(),
504
+ registry: pkg.registry,
505
+ packageName: pkg.name,
506
+ packageVersion: d.version,
507
+ decision: 'deny',
508
+ reason: d.reason,
509
+ source: 'metadata-filter',
510
+ requestPath: path,
511
+ });
512
+ }
513
+ // Audit log allowed versions (summary to avoid per-version verbosity)
514
+ if (allowedVersions.size > 0) {
515
+ writeAuditEntry(options.auditLogPath, {
516
+ timestamp: new Date().toISOString(),
517
+ registry: pkg.registry,
518
+ packageName: pkg.name,
519
+ packageVersion: `${allowedVersions.size} version(s)`,
520
+ decision: 'allow',
521
+ reason: 'Passed all validation checks',
522
+ source: 'metadata-filter',
523
+ requestPath: path,
524
+ });
525
+ }
526
+ if (denied.length > 0) {
527
+ logger.info(`[registry-proxy] PyPI ${pkg.name}: filtered ${denied.length} version(s), ${allowedVersions.size} allowed`);
528
+ }
529
+ clientRes.writeHead(200, {
530
+ 'Content-Type': 'text/html',
531
+ 'Content-Length': Buffer.byteLength(filtered),
532
+ });
533
+ clientRes.end(filtered);
534
+ }
535
+ catch (err) {
536
+ logger.info(`[registry-proxy] PyPI metadata error for ${pkg.name}: ${err instanceof Error ? err.message : String(err)}`);
537
+ clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
538
+ clientRes.end('Failed to process package index');
539
+ }
540
+ }
541
+ async function handleTarballDownload(registry, path, clientRes, host, port, options, registryType) {
542
+ const pkg = registryType === 'npm' ? parseNpmUrl(path) : parsePypiTarballUrl(path);
543
+ if (!pkg || !pkg.version) {
544
+ // Can't parse -- fail-closed
545
+ logger.info(`[registry-proxy] tarball backstop: can't parse URL, denying: ${path}`);
546
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
547
+ clientRes.end('Forbidden: unable to identify package from URL — ensure the package name and version are correct');
548
+ return;
549
+ }
550
+ // Check allowed version cache
551
+ const cached = getCachedVersions(options.cache, pkg);
552
+ if (cached !== undefined) {
553
+ if (cached.has(pkg.version)) {
554
+ // Allowed -- forward upstream
555
+ writeAuditEntry(options.auditLogPath, {
556
+ timestamp: new Date().toISOString(),
557
+ registry: pkg.registry,
558
+ packageName: pkg.name,
559
+ packageScope: pkg.scope,
560
+ packageVersion: pkg.version,
561
+ decision: 'allow',
562
+ reason: 'Version in allowed cache from metadata filtering',
563
+ source: 'tarball-backstop',
564
+ requestPath: path,
565
+ });
566
+ await forwardToUpstream(clientRes, host, port, path, { timeoutMs: 60_000 });
567
+ return;
568
+ }
569
+ // In cache but not in allowed set -- was filtered during metadata validation
570
+ const name = canonicalPackageName(pkg);
571
+ logger.info(`[registry-proxy] tarball backstop: denied ${name}@${pkg.version}`);
572
+ writeAuditEntry(options.auditLogPath, {
573
+ timestamp: new Date().toISOString(),
574
+ registry: pkg.registry,
575
+ packageName: pkg.name,
576
+ packageScope: pkg.scope,
577
+ packageVersion: pkg.version,
578
+ decision: 'deny',
579
+ reason: 'Version not in allowed cache (filtered from metadata)',
580
+ source: 'tarball-backstop',
581
+ requestPath: path,
582
+ });
583
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
584
+ clientRes.end(`Forbidden: ${name}@${pkg.version} was filtered during metadata validation (may be too new, denylisted, or otherwise disallowed). Try an older version.`);
585
+ return;
586
+ }
587
+ // Cache miss -- need to fetch metadata and validate
588
+ try {
589
+ const decision = await fetchAndValidateVersion(registry, pkg, options);
590
+ if (decision.status === 'allow') {
591
+ writeAuditEntry(options.auditLogPath, {
592
+ timestamp: new Date().toISOString(),
593
+ registry: pkg.registry,
594
+ packageName: pkg.name,
595
+ packageScope: pkg.scope,
596
+ packageVersion: pkg.version,
597
+ decision: 'allow',
598
+ reason: 'Version passed validation on cache miss',
599
+ source: 'tarball-backstop',
600
+ requestPath: path,
601
+ });
602
+ await forwardToUpstream(clientRes, host, port, path, { timeoutMs: 60_000 });
603
+ }
604
+ else {
605
+ const name = canonicalPackageName(pkg);
606
+ logger.info(`[registry-proxy] tarball backstop (cache miss): denied ${name}@${pkg.version}: ${decision.reason}`);
607
+ writeAuditEntry(options.auditLogPath, {
608
+ timestamp: new Date().toISOString(),
609
+ registry: pkg.registry,
610
+ packageName: pkg.name,
611
+ packageScope: pkg.scope,
612
+ packageVersion: pkg.version,
613
+ decision: 'deny',
614
+ reason: decision.reason,
615
+ source: 'tarball-backstop',
616
+ requestPath: path,
617
+ });
618
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
619
+ clientRes.end(`Forbidden: ${name}@${pkg.version} — ${decision.reason}. Try a different version.`);
620
+ }
621
+ }
622
+ catch (err) {
623
+ // Fail-closed
624
+ logger.info(`[registry-proxy] tarball backstop error: ${err instanceof Error ? err.message : String(err)}`);
625
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
626
+ clientRes.end('Forbidden: unable to validate package version (upstream metadata fetch failed). This is a fail-closed response — retry later or use an allowlisted package.');
627
+ }
628
+ }
629
+ async function handleDebianRequest(_registry, path, clientReq, clientRes, host, port, options) {
630
+ // parseDebianPackageUrl returns undefined for non-.deb paths (metadata,
631
+ // Release files, GPG keys) — those pass through unmodified since apt
632
+ // verifies them with GPG signatures.
633
+ const pkg = parseDebianPackageUrl(path);
634
+ if (!pkg) {
635
+ // Distinguish "not a .deb file" (pass-through) from "malformed .deb" (fail-closed)
636
+ const filename = path.split('?')[0].split('/').pop() ?? '';
637
+ if (filename.endsWith('.deb')) {
638
+ logger.info(`[registry-proxy] debian backstop: can't parse .deb filename, denying: ${path}`);
639
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
640
+ clientRes.end('Forbidden: unable to identify Debian package from URL');
641
+ return;
642
+ }
643
+ await forwardUpstream(clientReq, clientRes, host, port);
644
+ return;
645
+ }
646
+ // Debian packages are distro-curated — bypass quarantine by providing
647
+ // an epoch publish date. Only allow/denylist checks apply.
648
+ const decision = options.validator.validate(pkg, { publishedAt: new Date(0) });
649
+ writeAuditEntry(options.auditLogPath, {
650
+ timestamp: new Date().toISOString(),
651
+ registry: 'debian',
652
+ packageName: pkg.name,
653
+ packageVersion: pkg.version,
654
+ decision: decision.status,
655
+ reason: decision.reason,
656
+ source: 'deb-backstop',
657
+ requestPath: path,
658
+ });
659
+ if (decision.status === 'allow') {
660
+ await forwardUpstream(clientReq, clientRes, host, port);
661
+ }
662
+ else {
663
+ logger.info(`[registry-proxy] debian backstop: denied ${pkg.name}_${pkg.version}: ${decision.reason}`);
664
+ clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
665
+ clientRes.end(`Forbidden: Debian package ${pkg.name} (${pkg.version}) — ${decision.reason}`);
666
+ }
667
+ }
668
+ /**
669
+ * Fetches metadata for a package on cache miss and validates the
670
+ * requested version. Populates the cache as a side effect.
671
+ */
672
+ async function fetchAndValidateVersion(registry, pkg, options) {
673
+ if (registry.type === 'npm') {
674
+ const metadataPath = pkg.scope ? `/@${pkg.scope}/${pkg.name}` : `/${pkg.name}`;
675
+ const packument = await fetchUpstreamJson(registry.host, 443, metadataPath);
676
+ if (!packument)
677
+ return { status: 'deny', reason: 'Failed to fetch package metadata from upstream' };
678
+ const { filtered, denied } = filterNpmPackument(packument, options.validator, pkg.name, pkg.scope);
679
+ const allowedVersions = new Set(Object.keys(filtered.versions));
680
+ setCachedVersions(options.cache, pkg, allowedVersions);
681
+ if (pkg.version !== undefined && allowedVersions.has(pkg.version)) {
682
+ return { status: 'allow', reason: 'Version passed validation' };
683
+ }
684
+ const deniedEntry = denied.find((d) => d.version === pkg.version);
685
+ return { status: 'deny', reason: deniedEntry?.reason ?? 'Version not found in package metadata' };
686
+ }
687
+ // PyPI: fetch JSON API for timestamps, validate ALL versions, populate cache
688
+ const versionTimestamps = await fetchPypiVersionTimestamps(registry.host, 443, pkg.name);
689
+ const allowedVersions = new Set();
690
+ let requestedVersionReason;
691
+ for (const [version, publishedAt] of versionTimestamps) {
692
+ const decision = options.validator.validate({ registry: 'pypi', name: pkg.name, version }, { publishedAt });
693
+ if (decision.status === 'allow') {
694
+ allowedVersions.add(version);
695
+ }
696
+ else if (version === pkg.version) {
697
+ requestedVersionReason = decision.reason;
698
+ }
699
+ }
700
+ setCachedVersions(options.cache, pkg, allowedVersions);
701
+ if (pkg.version !== undefined && allowedVersions.has(pkg.version)) {
702
+ return { status: 'allow', reason: 'Version passed validation' };
703
+ }
704
+ return { status: 'deny', reason: requestedVersionReason ?? 'Version not found in package metadata' };
705
+ }
706
+ // ── HTTP helpers ────────────────────────────────────────────────────
707
+ /**
708
+ * Fetches a response body from upstream registry via HTTPS.
709
+ * Returns the raw Buffer, or undefined on non-200 or error.
710
+ */
711
+ async function fetchUpstream(host, port, path, accept) {
712
+ return new Promise((resolve) => {
713
+ const req = https.request({
714
+ hostname: host,
715
+ port,
716
+ path,
717
+ method: 'GET',
718
+ headers: { accept, host },
719
+ }, (res) => {
720
+ const chunks = [];
721
+ res.on('data', (chunk) => chunks.push(chunk));
722
+ res.on('end', () => {
723
+ if (res.statusCode !== 200) {
724
+ resolve(undefined);
725
+ return;
726
+ }
727
+ resolve(Buffer.concat(chunks));
728
+ });
729
+ res.on('error', () => resolve(undefined));
730
+ });
731
+ req.on('error', () => resolve(undefined));
732
+ req.setTimeout(30_000, () => {
733
+ req.destroy();
734
+ resolve(undefined);
735
+ });
736
+ req.end();
737
+ });
738
+ }
739
+ /**
740
+ * Fetches JSON from upstream registry via HTTPS.
741
+ */
742
+ async function fetchUpstreamJson(host, port, path) {
743
+ const buf = await fetchUpstream(host, port, path, 'application/json');
744
+ if (!buf)
745
+ return undefined;
746
+ try {
747
+ return JSON.parse(buf.toString());
748
+ }
749
+ catch {
750
+ return undefined;
751
+ }
752
+ }
753
+ /**
754
+ * Fetches text content from upstream registry via HTTPS.
755
+ */
756
+ async function fetchUpstreamText(host, port, path, accept) {
757
+ const buf = await fetchUpstream(host, port, path, accept);
758
+ return buf?.toString();
759
+ }
760
+ /**
761
+ * Fetches PyPI version timestamps from the JSON API.
762
+ * Returns a map of version -> publish date.
763
+ */
764
+ async function fetchPypiVersionTimestamps(host, port, packageName) {
765
+ const timestamps = new Map();
766
+ const data = await fetchUpstreamJson(host, port, `/pypi/${packageName}/json`);
767
+ if (!data?.releases)
768
+ return timestamps;
769
+ for (const [version, files] of Object.entries(data.releases)) {
770
+ // Use the earliest upload timestamp among the version's files
771
+ let earliest;
772
+ for (const file of files) {
773
+ if (file.upload_time_iso_8601) {
774
+ const date = new Date(file.upload_time_iso_8601);
775
+ if (!earliest || date < earliest)
776
+ earliest = date;
777
+ }
778
+ }
779
+ if (earliest)
780
+ timestamps.set(version, earliest);
781
+ }
782
+ return timestamps;
783
+ }
784
+ /**
785
+ * Forwards a request to the upstream HTTPS server and pipes the response
786
+ * back to the client. Supports both pass-through (with client request
787
+ * body piping) and simple GET forwarding modes.
788
+ */
789
+ async function forwardToUpstream(clientRes, host, port, path, options = {}) {
790
+ const { clientReq, forwardHeaders = !!clientReq, timeoutMs = 30_000 } = options;
791
+ return new Promise((resolve) => {
792
+ const headers = forwardHeaders && clientReq ? { ...clientReq.headers, host } : { host };
793
+ const upstreamReq = https.request({
794
+ hostname: host,
795
+ port,
796
+ method: clientReq?.method ?? 'GET',
797
+ path: clientReq?.url ?? path,
798
+ headers,
799
+ }, (upstreamRes) => {
800
+ clientRes.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
801
+ upstreamRes.pipe(clientRes);
802
+ upstreamRes.on('end', resolve);
803
+ upstreamRes.on('error', () => {
804
+ if (!clientRes.headersSent) {
805
+ clientRes.writeHead(502);
806
+ clientRes.end('Upstream error');
807
+ }
808
+ resolve();
809
+ });
810
+ });
811
+ upstreamReq.on('error', () => {
812
+ if (!clientRes.headersSent) {
813
+ clientRes.writeHead(502);
814
+ clientRes.end('Upstream connection error');
815
+ }
816
+ resolve();
817
+ });
818
+ upstreamReq.setTimeout(timeoutMs, () => {
819
+ upstreamReq.destroy();
820
+ if (!clientRes.headersSent) {
821
+ clientRes.writeHead(504);
822
+ clientRes.end('Upstream timeout');
823
+ }
824
+ resolve();
825
+ });
826
+ if (clientReq) {
827
+ clientReq.pipe(upstreamReq);
828
+ }
829
+ else {
830
+ upstreamReq.end();
831
+ }
832
+ });
833
+ }
834
+ /**
835
+ * Forwards a request upstream without modification (pass-through).
836
+ * Pipes the client request body and forwards all client headers.
837
+ */
838
+ async function forwardUpstream(clientReq, clientRes, host, port) {
839
+ await forwardToUpstream(clientRes, host, port, clientReq.url ?? '/', {
840
+ clientReq,
841
+ forwardHeaders: true,
842
+ });
843
+ }
844
+ // ── Audit logging ───────────────────────────────────────────────────
845
+ /**
846
+ * Writes a package audit entry to the JSONL audit log.
847
+ * Fire-and-forget: callers should not await the returned promise.
848
+ */
849
+ function writeAuditEntry(auditLogPath, entry) {
850
+ if (!auditLogPath)
851
+ return;
852
+ fs.appendFile(auditLogPath, JSON.stringify(entry) + '\n').catch((err) => {
853
+ logger.warn(`[registry-proxy] Failed to write audit entry: ${err instanceof Error ? err.message : String(err)}`);
854
+ });
855
+ }
856
+ //# sourceMappingURL=registry-proxy.js.map