sentinelayer-cli 0.46.5 → 0.48.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 (133) hide show
  1. package/README.md +47 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +32 -1
  26. package/src/auth/session-store.js +37 -0
  27. package/src/board/evidence-resolvers.js +3 -4
  28. package/src/cli.js +23 -3
  29. package/src/commands/ai/identity-lifecycle.js +30 -26
  30. package/src/commands/ai/provision-governance.js +32 -24
  31. package/src/commands/ai/shared.js +6 -4
  32. package/src/commands/audit.js +14 -12
  33. package/src/commands/board.js +2 -2
  34. package/src/commands/chat.js +8 -6
  35. package/src/commands/judge.js +125 -0
  36. package/src/commands/legacy-args.js +1 -0
  37. package/src/commands/mcp.js +45 -52
  38. package/src/commands/omargate.js +2 -1
  39. package/src/commands/plugin.js +11 -3
  40. package/src/commands/review.js +9 -4
  41. package/src/commands/scan.js +7 -7
  42. package/src/commands/session.js +516 -30
  43. package/src/commands/skills.js +45 -0
  44. package/src/commands/watch.js +9 -7
  45. package/src/config/output-filesystem.js +156 -0
  46. package/src/config/schema.js +4 -2
  47. package/src/config/service.js +40 -4
  48. package/src/cost/history.js +15 -9
  49. package/src/daemon/artifact-lineage.js +15 -12
  50. package/src/daemon/assignment-ledger.js +31 -25
  51. package/src/daemon/ast-drift.js +7 -3
  52. package/src/daemon/budget-governor.js +26 -20
  53. package/src/daemon/error-worker.js +28 -22
  54. package/src/daemon/fix-cycle.js +5 -4
  55. package/src/daemon/hybrid-mapper.js +19 -16
  56. package/src/daemon/ingest-refresh.js +4 -0
  57. package/src/daemon/jira-lifecycle.js +21 -17
  58. package/src/daemon/operator-control.js +18 -15
  59. package/src/daemon/reliability-lane.js +18 -15
  60. package/src/daemon/scope-engine.js +12 -8
  61. package/src/daemon/watchdog.js +15 -12
  62. package/src/engram/build-state.js +24 -3
  63. package/src/engram/capability-index.js +367 -0
  64. package/src/engram/compile-graph.js +1063 -0
  65. package/src/engram/contracts/action-receipt.js +82 -0
  66. package/src/engram/contracts/capability-index.js +259 -0
  67. package/src/engram/contracts/capability.js +377 -0
  68. package/src/engram/contracts/content-hash.js +8 -0
  69. package/src/engram/contracts/evidence.js +258 -0
  70. package/src/engram/contracts/graph.js +594 -0
  71. package/src/engram/contracts/identity.js +8 -0
  72. package/src/engram/contracts/index.js +149 -0
  73. package/src/engram/contracts/media-asset.js +85 -0
  74. package/src/engram/contracts/money.js +249 -0
  75. package/src/engram/contracts/source-snapshot.js +57 -0
  76. package/src/engram/contracts/task-context-pack.js +114 -0
  77. package/src/engram/contracts/temporal-segment.js +72 -0
  78. package/src/engram/contracts/versions.js +96 -0
  79. package/src/engram/extract-html.js +692 -0
  80. package/src/engram/graph-store.js +877 -0
  81. package/src/engram/index.js +98 -14
  82. package/src/engram/ingest-site.js +132 -0
  83. package/src/engram/live-state.js +1180 -0
  84. package/src/engram/namespace.js +34 -1
  85. package/src/engram/retrieve-site.js +753 -0
  86. package/src/engram/safe-reader.js +597 -0
  87. package/src/engram/source-intake.js +251 -0
  88. package/src/engram/store.js +227 -9
  89. package/src/ingest/engine.js +21 -28
  90. package/src/judge/verdict.js +305 -0
  91. package/src/legacy-cli.js +96 -19
  92. package/src/mcp/bridge-inputs.js +25 -4
  93. package/src/mcp/cli-command-tools.js +70 -0
  94. package/src/mcp/registry.js +17 -13
  95. package/src/mcp/session-stdio-server.js +297 -19
  96. package/src/memory/blackboard.js +5 -4
  97. package/src/net/gh-api-path.js +35 -9
  98. package/src/net/test-egress-guard.js +16 -4
  99. package/src/plugin/manifest.js +12 -8
  100. package/src/review/ai-review.js +110 -10
  101. package/src/review/investor-dd-devtestbot.js +17 -6
  102. package/src/review/investor-dd-orchestrator.js +21 -13
  103. package/src/review/local-review.js +26 -16
  104. package/src/review/omargate-cache.js +5 -5
  105. package/src/review/omargate-orchestrator.js +89 -17
  106. package/src/review/replay.js +9 -4
  107. package/src/review/report.js +22 -13
  108. package/src/review/typesafe-severity.js +201 -0
  109. package/src/session/admission-auth.js +34 -7
  110. package/src/session/admission.js +110 -15
  111. package/src/session/attachment-summary.js +77 -0
  112. package/src/session/edit-summary.js +19 -0
  113. package/src/session/invitations.js +65 -1
  114. package/src/session/mentions.js +17 -9
  115. package/src/session/message-edits.js +5 -4
  116. package/src/session/preview.js +10 -2
  117. package/src/session/recap.js +3 -2
  118. package/src/session/room-files.js +519 -0
  119. package/src/session/route-classes.js +22 -8
  120. package/src/session/setup-guides.js +4 -0
  121. package/src/session/standup.js +192 -0
  122. package/src/session/sync.js +229 -10
  123. package/src/session/tickets.js +41 -0
  124. package/src/session/transcript.js +2 -1
  125. package/src/session/wake/resolve-target.js +7 -3
  126. package/src/skills/listener-runtime.cjs +92 -0
  127. package/src/skills/service.js +207 -0
  128. package/src/swarm/factory.js +5 -5
  129. package/src/swarm/pentest.js +9 -9
  130. package/src/swarm/report.js +4 -4
  131. package/src/swarm/runtime.js +15 -12
  132. package/src/swarm/scenario-dsl.js +7 -5
  133. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,597 @@
1
+ /**
2
+ * ENGRAM §EW04 — SSRF-safe source reader.
3
+ *
4
+ * Fetches an owner-approved public URL with defence-in-depth against SSRF:
5
+ * - DNS vetting: all resolved addresses checked against private/metadata ranges
6
+ * - IP pinning: connection made to the vetted IP, not the hostname
7
+ * - HTTPS only (http only via test-only injected transport)
8
+ * - Manual redirects (max 3), full re-check on every hop
9
+ * - 10 s total deadline, 5 MiB body cap, content-type allowlist
10
+ * - No cookies, credentials, or Authorization header
11
+ * - Generic errors: never echo resolved IPs or response bodies of refused fetches
12
+ *
13
+ * Library only. Node builtins + existing zod contracts. No new dependency.
14
+ * No headless browser or JavaScript execution.
15
+ */
16
+
17
+ import { promises as dnsPromises } from "node:dns";
18
+ import https from "node:https";
19
+ import http from "node:http";
20
+ import { createHash } from "node:crypto";
21
+ import { isIP } from "node:net";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
28
+ export const MAX_REDIRECT_HOPS = 3;
29
+ export const TOTAL_DEADLINE_MS = 10_000; // 10 s
30
+
31
+ const ALLOWED_CONTENT_TYPES = new Set([
32
+ "text/html",
33
+ "application/json",
34
+ "application/ld+json",
35
+ "text/plain",
36
+ "application/xml",
37
+ "text/xml",
38
+ ]);
39
+
40
+ const REQUEST_HEADERS = {
41
+ "Accept": "text/html, application/json, application/ld+json, text/plain, application/xml, text/xml",
42
+ "User-Agent": "ENGRAM-SafeReader/1.0",
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Address classification — every private/metadata range from the ticket
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const IPV4_PRIVATE_RANGES = [
50
+ { start: 0x00000000, end: 0x00ffffff, label: "0/8" }, // 0.0.0.0/8
51
+ { start: 0x0a000000, end: 0x0affffff, label: "10/8" }, // 10.0.0.0/8
52
+ { start: 0x7f000000, end: 0x7fffffff, label: "127/8" }, // 127.0.0.0/8
53
+ { start: 0xa9fe0000, end: 0xa9feffff, label: "169.254/16" }, // 169.254.0.0/16 (includes 169.254.169.254)
54
+ { start: 0xac100000, end: 0xac1fffff, label: "172.16/12" }, // 172.16.0.0/12
55
+ { start: 0xc0a80000, end: 0xc0a8ffff, label: "192.168/16" }, // 192.168.0.0/16
56
+ { start: 0x64400000, end: 0x647fffff, label: "100.64/10" }, // 100.64.0.0/10 (CGNAT)
57
+ { start: 0xe0000000, end: 0xefffffff, label: "224/4" }, // 224.0.0.0/4 (multicast)
58
+ { start: 0xf0000000, end: 0xffffffff, label: "240/4" }, // 240.0.0.0/4 (reserved, incl 255.255.255.255)
59
+ { start: 0xc0000000, end: 0xc00000ff, label: "192.0.0/24" }, // 192.0.0.0/24 (IETF protocol assignments)
60
+ { start: 0xc6120000, end: 0xc613ffff, label: "198.18/15" }, // 198.18.0.0/15 (benchmarking)
61
+ ];
62
+
63
+ function ipv4ToInt(ip) {
64
+ const parts = ip.split(".");
65
+ if (parts.length !== 4) return null;
66
+ const nums = parts.map((p) => { const n = Number(p); return (n >= 0 && n <= 255 && String(n) === p) ? n : null; });
67
+ if (nums.includes(null)) return null;
68
+ return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
69
+ }
70
+
71
+ function isPrivateIPv4(ip) {
72
+ const int = ipv4ToInt(ip);
73
+ if (int === null) return false;
74
+ for (const range of IPV4_PRIVATE_RANGES) {
75
+ if (int >= range.start && int <= range.end) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // IPv6 private and special-purpose ranges (normalized, uncompressed form)
81
+ const IPV6_PRIVATE_NETWORKS = [
82
+ { prefixBits: 0x00000000000000000000000000000000n, len: 16, label: "::/16 (unspecified/IPv4-mapped)" },
83
+ // ::1 is handled by the raw string check above; ::/16 below catches unspecified
84
+ { prefixBits: 0xfc000000000000000000000000000000n, len: 7, label: "fc00::/7 (unique-local)" },
85
+ { prefixBits: 0xfe800000000000000000000000000000n, len: 10, label: "fe80::/10 (link-local)" },
86
+ { prefixBits: 0xff000000000000000000000000000000n, len: 8, label: "ff00::/8 (multicast)" },
87
+ // R7-4: special-purpose ranges (RFC 6890, RFC 3849, RFC 6666, RFC 2928)
88
+ { prefixBits: 0x20010db8000000000000000000000000n, len: 32, label: "2001:db8::/32 (documentation)" },
89
+ { prefixBits: 0x01000000000000000000000000000000n, len: 64, label: "100::/64 (discard)" },
90
+ { prefixBits: 0x20010000000000000000000000000000n, len: 23, label: "2001::/23 (IETF protocol assignments)" },
91
+ ];
92
+
93
+ // NAT64 well-known prefix: 64:ff9b::/96 — 12 bytes (24 hex chars) in expanded flat form
94
+ const NAT64_PREFIX_BITS = 0x0064ff9b000000000000000000000000n;
95
+
96
+ // 6to4: 2002::/16 — 2 bytes (4 hex chars) in expanded flat form, then embedded IPv4 at bytes 2-5 (hex 4-12)
97
+ const SIXTO4_PREFIX = "2002";
98
+
99
+ function expandIPv6(ip) {
100
+ // Remove brackets if present
101
+ const raw = ip.replace(/^\[|\]$/g, "");
102
+
103
+ // Detect dotted IPv4 tail: last group matches a.b.c.d with each octet 0-255.
104
+ const dottedV4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
105
+ const convertDottedTail = (groups) => {
106
+ if (groups.length === 0) return groups;
107
+ const last = groups[groups.length - 1];
108
+ const m = last.match(dottedV4Re);
109
+ if (!m) return groups;
110
+ const octets = m.slice(1).map(Number);
111
+ if (octets.some((o) => o > 255 || o < 0)) return groups;
112
+ const h1 = ((octets[0] << 8) | octets[1]).toString(16).padStart(4, "0");
113
+ const h2 = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, "0");
114
+ return [...groups.slice(0, -1), h1, h2];
115
+ };
116
+
117
+ // Handle :: compression
118
+ const parts = raw.split("::");
119
+ if (parts.length > 2) return null;
120
+ const left = parts[0] ? parts[0].split(":") : [];
121
+ const right = parts[1] ? parts[1].split(":") : [];
122
+
123
+ // No compression case: 7 groups with a dotted tail (e.g. 0:0:0:0:0:ffff:127.0.0.1)
124
+ // expands to 8; 8 fully-expanded groups also handled here.
125
+ if (parts.length === 1) {
126
+ const norm = convertDottedTail(left);
127
+ // After normalization, must be exactly 8 valid hextets
128
+ if (norm.length !== 8 || norm.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null;
129
+ return norm.map((g) => g.padStart(4, "0").toLowerCase()).join(":");
130
+ }
131
+
132
+ const normalizedLeft = convertDottedTail(left);
133
+ const normalizedRight = convertDottedTail(right);
134
+
135
+ if (normalizedLeft.length + normalizedRight.length > 7) return null;
136
+ const middle = Array(8 - normalizedLeft.length - normalizedRight.length).fill("0");
137
+ const all = [...normalizedLeft, ...middle, ...normalizedRight];
138
+ if (all.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null;
139
+ return all.map((g) => g.padStart(4, "0").toLowerCase()).join(":");
140
+ }
141
+
142
+ function ipv6ToBigInt(expanded) {
143
+ const flat = expanded.replace(/:/g, "");
144
+ return BigInt("0x" + flat);
145
+ }
146
+
147
+ function isPrivateIPv6(ip) {
148
+ const raw = ip.replace(/^\[|\]$/g, "").toLowerCase();
149
+ // ::1
150
+ if (raw === "::1" || raw === "0:0:0:0:0:0:0:1") return true;
151
+
152
+ // IPv4-mapped IPv6: ::ffff:x.x.x.x
153
+ const v4Mapped = raw.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
154
+ if (v4Mapped) return isPrivateIPv4(v4Mapped[1]);
155
+
156
+ const expanded = expandIPv6(ip);
157
+ // FAIL CLOSED: if we cannot normalize an IPv6 address, treat it as dangerous.
158
+ // Valid IPv6 addresses must normalize to canonical 8-hextet colon form.
159
+ if (!expanded) return true;
160
+
161
+ // BigInt-based numeric prefix matching
162
+ const addrBits = ipv6ToBigInt(expanded);
163
+
164
+ for (const net of IPV6_PRIVATE_NETWORKS) {
165
+ const shift = 128n - BigInt(net.len);
166
+ const mask = (~0n) << shift;
167
+ if ((addrBits & mask) === (net.prefixBits & mask)) return true;
168
+ }
169
+
170
+ // NAT64: 64:ff9b::/96 — extract last 4 bytes as IPv4
171
+ const NAT64_MASK = (~0n) << 32n;
172
+ if ((addrBits & NAT64_MASK) === NAT64_PREFIX_BITS) {
173
+ const embeddedV4 = Number(addrBits & 0xffffffffn);
174
+ const octets = [
175
+ (embeddedV4 >>> 24) & 0xff,
176
+ (embeddedV4 >>> 16) & 0xff,
177
+ (embeddedV4 >>> 8) & 0xff,
178
+ embeddedV4 & 0xff,
179
+ ];
180
+ return isPrivateIPv4(octets.join("."));
181
+ }
182
+
183
+ // 6to4: 2002::/16 — extract embedded IPv4 at bytes 2-5 (bits 16-47)
184
+ const SIXTO4_MASK = (~0n) << 112n;
185
+ if ((addrBits & SIXTO4_MASK) === 0x20020000000000000000000000000000n) {
186
+ const embeddedV4 = Number((addrBits >> 80n) & 0xffffffffn);
187
+ const octets = [
188
+ (embeddedV4 >>> 24) & 0xff,
189
+ (embeddedV4 >>> 16) & 0xff,
190
+ (embeddedV4 >>> 8) & 0xff,
191
+ embeddedV4 & 0xff,
192
+ ];
193
+ return isPrivateIPv4(octets.join("."));
194
+ }
195
+
196
+ // IPv4-compatible IPv6 (deprecated): first 96 bits zero, last 32 bits are IPv4
197
+ // Skip ::1 (already handled above) and :: (unspecified, caught by prefix match)
198
+ if ((addrBits >> 32n) === 0n && (addrBits & 0xffffffffn) !== 0n && (addrBits & 0xffffffffn) !== 1n) {
199
+ const embeddedV4 = Number(addrBits & 0xffffffffn);
200
+ const octets = [
201
+ (embeddedV4 >>> 24) & 0xff,
202
+ (embeddedV4 >>> 16) & 0xff,
203
+ (embeddedV4 >>> 8) & 0xff,
204
+ embeddedV4 & 0xff,
205
+ ];
206
+ const v4Addr = octets.join(".");
207
+ if (isIP(v4Addr) === 4) return isPrivateIPv4(v4Addr);
208
+ }
209
+
210
+ return false;
211
+ }
212
+
213
+ function isPrivateAddress(addr) {
214
+ if (isIP(addr) === 4) return isPrivateIPv4(addr);
215
+ if (isIP(addr) === 6) return isPrivateIPv6(addr);
216
+ return false;
217
+ }
218
+
219
+ function isMetadataAddress(addr) {
220
+ return addr === "169.254.169.254";
221
+ }
222
+
223
+ function isDangerousAddress(addr) {
224
+ // isPrivateAddress already fails closed for unparseable IPv6.
225
+ // But we double-check: if net.isIP says this is a v6 address and we cannot
226
+ // produce a canonical form, it is dangerous regardless.
227
+ if (isIP(addr) === 6) {
228
+ const normalized = expandIPv6(addr);
229
+ if (!normalized) return true;
230
+ }
231
+ if (isIP(addr) === 4) {
232
+ if (ipv4ToInt(addr) === null) return true;
233
+ }
234
+ // Anything that isPrivateAddress cannot classify and isIP cannot identify:
235
+ // we don't try to classify it here — let the caller decide (resolveAndVet
236
+ // will refuse anything that isn't isIP 4 or 6).
237
+ return isPrivateAddress(addr) || isMetadataAddress(addr);
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Errors
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export class SafeReaderError extends Error {
245
+ constructor(code) {
246
+ super(code);
247
+ this.name = "SafeReaderError";
248
+ this.code = code;
249
+ }
250
+ }
251
+
252
+ function reject(code) {
253
+ throw new SafeReaderError(code);
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // DNS resolution + vetting
258
+ // ---------------------------------------------------------------------------
259
+
260
+ async function resolveAndVet(hostname, resolver, signal) {
261
+ // Check deadline before starting DNS
262
+ if (signal && signal.aborted) {
263
+ return { vetted: null, error: "fetch_timeout" };
264
+ }
265
+
266
+ if (isIP(hostname)) {
267
+ // Raw IP — accept ONLY if isIP returns 4 or 6 AND it is not dangerous.
268
+ // An unclassifiable address (isIP returns 0) is never dialed.
269
+ const kind = isIP(hostname);
270
+ if (kind !== 4 && kind !== 6) {
271
+ return { vetted: null, error: "fetch_private_address" };
272
+ }
273
+ if (isDangerousAddress(hostname)) {
274
+ return { vetted: null, error: "fetch_private_address" };
275
+ }
276
+ return { vetted: hostname, error: null };
277
+ }
278
+
279
+ // Resolve all addresses, racing against the deadline signal
280
+ let v4 = [];
281
+ let v6 = [];
282
+ try {
283
+ const dnsPromise = Promise.all([
284
+ resolver.resolve4(hostname).catch(() => []),
285
+ resolver.resolve6(hostname).catch(() => []),
286
+ ]);
287
+
288
+ if (signal) {
289
+ const timeoutPromise = new Promise((_, reject) => {
290
+ signal.addEventListener("abort", () => reject(new SafeReaderError("fetch_timeout")), { once: true });
291
+ if (signal.aborted) reject(new SafeReaderError("fetch_timeout"));
292
+ });
293
+ [v4, v6] = await Promise.race([dnsPromise, timeoutPromise]);
294
+ } else {
295
+ [v4, v6] = await dnsPromise;
296
+ }
297
+ } catch (err) {
298
+ if (err instanceof SafeReaderError) {
299
+ return { vetted: null, error: err.code };
300
+ }
301
+ return { vetted: null, error: "fetch_dns_failed" };
302
+ }
303
+
304
+ const allAddrs = [...v4, ...v6];
305
+ if (allAddrs.length === 0) {
306
+ return { vetted: null, error: "fetch_dns_failed" };
307
+ }
308
+
309
+ // Always refuse if ANY address is dangerous or unclassifiable.
310
+ // An address is acceptable ONLY if isIP returns 4 or 6 AND it passes
311
+ // the danger check (which fails closed for unparseable IPv6).
312
+ for (const addr of allAddrs) {
313
+ const kind = isIP(addr);
314
+ if (kind !== 4 && kind !== 6) {
315
+ return { vetted: null, error: "fetch_private_address" };
316
+ }
317
+ if (isDangerousAddress(addr)) {
318
+ return { vetted: null, error: "fetch_private_address" };
319
+ }
320
+ }
321
+
322
+ // Pin to the first IPv4 (or first IPv6 if no IPv4)
323
+ const vetted = v4.length > 0 ? v4[0] : v6[0];
324
+ return { vetted, error: null };
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Unified request + body read (de-chunked by Node)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * Make an HTTP(S) request and read the response with the 5 MiB cap enforced
333
+ * in the data handler. Uses Node's request API so Transfer-Encoding: chunked
334
+ * is decoded automatically.
335
+ */
336
+ function doRequest({ transport, hostname, ip, port, path, signal }) {
337
+ return new Promise((resolve, reject) => {
338
+ const lookup = (host, opts, cb) => {
339
+ const family = isIP(ip) === 6 ? 6 : 4;
340
+ const dialIp = ip;
341
+ if (opts.all) {
342
+ cb(null, [{ address: dialIp, family }]);
343
+ } else {
344
+ cb(null, dialIp, family);
345
+ }
346
+ };
347
+
348
+ const reqOpts = {
349
+ host: hostname,
350
+ port,
351
+ path: path || "/",
352
+ method: "GET",
353
+ agent: false,
354
+ signal,
355
+ lookup,
356
+ headers: { ...REQUEST_HEADERS },
357
+ };
358
+
359
+ // Only set servername/rejectUnauthorized for https
360
+ if (transport === https) {
361
+ reqOpts.servername = hostname;
362
+ reqOpts.rejectUnauthorized = true;
363
+ }
364
+
365
+ const req = transport.request(reqOpts);
366
+
367
+ req.on("error", (err) => {
368
+ if (err.name === "AbortError") {
369
+ reject(new SafeReaderError("fetch_timeout"));
370
+ } else {
371
+ reject(new SafeReaderError("fetch_io_error"));
372
+ }
373
+ });
374
+
375
+ req.on("response", (resp) => {
376
+ let bodyLength = 0;
377
+ const chunks = [];
378
+
379
+ resp.on("data", (chunk) => {
380
+ bodyLength += chunk.length;
381
+ if (bodyLength > MAX_BODY_BYTES) {
382
+ resp.destroy();
383
+ reject(new SafeReaderError("fetch_too_large"));
384
+ return;
385
+ }
386
+ chunks.push(chunk);
387
+ });
388
+
389
+ resp.on("end", () => {
390
+ const body = Buffer.concat(chunks, bodyLength);
391
+ const contentType = resp.headers["content-type"] || "";
392
+ resolve({
393
+ status: resp.statusCode,
394
+ location: resp.headers["location"] || null,
395
+ body,
396
+ contentType,
397
+ });
398
+ });
399
+
400
+ resp.on("error", (err) => {
401
+ if (err.name === "AbortError") {
402
+ reject(new SafeReaderError("fetch_timeout"));
403
+ } else {
404
+ reject(new SafeReaderError("fetch_io_error"));
405
+ }
406
+ });
407
+ });
408
+
409
+ req.end();
410
+ });
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Main fetch function (internal)
415
+ // ---------------------------------------------------------------------------
416
+
417
+ async function fetchOne({
418
+ url,
419
+ approvedOrigins,
420
+ resolver,
421
+ transport,
422
+ deadlineSignal,
423
+ }) {
424
+ const parsed = new URL(url);
425
+
426
+ // Protocol check
427
+ if (parsed.protocol !== "https:") {
428
+ if (parsed.protocol === "http:" && (transport === http || transport._isTestHttp)) {
429
+ // http only with injected test transport
430
+ } else {
431
+ reject("fetch_blocked");
432
+ }
433
+ }
434
+
435
+ // Origin must be in approved set
436
+ if (!approvedOrigins.has(parsed.origin)) {
437
+ reject("fetch_blocked");
438
+ }
439
+
440
+ // No userinfo
441
+ if (parsed.username || parsed.password) {
442
+ reject("fetch_blocked");
443
+ }
444
+
445
+ // DNS vet
446
+ const hostname = parsed.hostname;
447
+ const { vetted, error } = await resolveAndVet(hostname, resolver, deadlineSignal);
448
+
449
+ if (error) reject(error);
450
+ if (!vetted) reject("fetch_dns_failed");
451
+
452
+ // Check deadline
453
+ if (deadlineSignal.aborted) reject("fetch_timeout");
454
+
455
+ const port = parsed.port || (parsed.protocol === "https:" ? 443 : 80);
456
+
457
+ const result = await doRequest({
458
+ transport,
459
+ hostname,
460
+ ip: vetted,
461
+ port,
462
+ path: parsed.pathname + parsed.search,
463
+ signal: deadlineSignal,
464
+ });
465
+
466
+ const status = result.status;
467
+ const responseBody = result.body;
468
+ const contentType = result.contentType;
469
+ const mediaType = contentType.split(";")[0].trim().toLowerCase();
470
+
471
+ if (status === null || status < 200 || status >= 300) {
472
+ // Check for redirect
473
+ if (result.location && [301, 302, 303, 307, 308].includes(status)) {
474
+ return { redirect: result.location, status };
475
+ }
476
+ reject("fetch_bad_status");
477
+ }
478
+
479
+ // Content-type check
480
+ if (!ALLOWED_CONTENT_TYPES.has(mediaType)) {
481
+ reject("fetch_bad_content_type");
482
+ }
483
+
484
+ return {
485
+ bytes: responseBody,
486
+ contentType: contentType,
487
+ finalUrl: url,
488
+ };
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Public API: createSafeReader
493
+ // ---------------------------------------------------------------------------
494
+
495
+ /**
496
+ * Create a safe reader configured with the given dependencies.
497
+ *
498
+ * @param {object} options
499
+ * @param {object} [options.resolver] — dns.promises by default; injectable for tests
500
+ * @param {function} [options.now] — Date.now by default; injectable for tests
501
+ * @param {object} [options.limits] — override limits
502
+ * @param {object} [options.transport] — node:https by default; inject a test transport for tests only
503
+ * Test transport MUST have .request(opts) and _isTestHttp:true flag
504
+ * @returns {{ read: function }}
505
+ */
506
+ export function createSafeReader({
507
+ resolver = dnsPromises,
508
+ now = () => Date.now(),
509
+ limits = {},
510
+ transport = https,
511
+ } = {}) {
512
+ const maxBytes = limits.maxBytes ?? MAX_BODY_BYTES;
513
+ const maxRedirects = limits.maxRedirects ?? MAX_REDIRECT_HOPS;
514
+ const deadlineMs = limits.deadlineMs ?? TOTAL_DEADLINE_MS;
515
+
516
+ /**
517
+ * Read a URL, returning bytes, content type, final URL, version hash, and fetch time.
518
+ *
519
+ * @param {string} url — the URL to fetch
520
+ * @param {Set<string>} approvedOrigins — allowed origins
521
+ * @returns {Promise<{bytes: Buffer, contentType: string, finalUrl: string, version: string, fetchedAt: string}>}
522
+ */
523
+ async function read(url, opts) {
524
+ if (!opts || !(opts.approvedOrigins instanceof Set)) reject("fetch_blocked");
525
+ const { approvedOrigins } = opts;
526
+
527
+ const startTime = now();
528
+ const deadline = startTime + deadlineMs;
529
+
530
+ const controller = new AbortController();
531
+ const deadlineTimer = setTimeout(() => controller.abort(), deadlineMs);
532
+
533
+ try {
534
+ let currentUrl = url;
535
+ let result;
536
+
537
+ for (let hop = 0; hop <= maxRedirects; hop++) {
538
+ if (controller.signal.aborted) reject("fetch_timeout");
539
+
540
+ result = await fetchOne({
541
+ url: currentUrl,
542
+ approvedOrigins,
543
+ resolver,
544
+ transport,
545
+ deadlineSignal: controller.signal,
546
+ });
547
+
548
+ if (!result.redirect) break;
549
+
550
+ // Resolve redirect target
551
+ let nextUrl;
552
+ try {
553
+ nextUrl = new URL(result.redirect, currentUrl).href;
554
+ } catch {
555
+ reject("fetch_redirect_rejected");
556
+ }
557
+
558
+ // Check redirect target origin is approved
559
+ const nextOrigin = new URL(nextUrl).origin;
560
+ if (!approvedOrigins.has(nextOrigin)) {
561
+ reject("fetch_redirect_rejected");
562
+ }
563
+
564
+ // DNS vet on redirect target
565
+ const nextHostname = new URL(nextUrl).hostname;
566
+ const { vetted: nextVetted, error: nextError } = await resolveAndVet(nextHostname, resolver, controller.signal);
567
+ if (nextError) reject(nextError);
568
+ if (!nextVetted) reject("fetch_redirect_rejected");
569
+ if (isDangerousAddress(nextVetted)) reject("fetch_redirect_rejected");
570
+
571
+ // Cycle detection
572
+ if (nextUrl === currentUrl) reject("fetch_redirect_loop");
573
+
574
+ currentUrl = nextUrl;
575
+ }
576
+
577
+ if (result.redirect) {
578
+ reject("fetch_redirect_loop"); // exceeded max hops
579
+ }
580
+
581
+ const fetchedAt = new Date(now()).toISOString();
582
+ const version = `sha256:${createHash("sha256").update(result.bytes).digest("hex")}`;
583
+
584
+ return {
585
+ bytes: result.bytes,
586
+ contentType: result.contentType,
587
+ finalUrl: result.finalUrl,
588
+ version,
589
+ fetchedAt,
590
+ };
591
+ } finally {
592
+ clearTimeout(deadlineTimer);
593
+ }
594
+ }
595
+
596
+ return { read };
597
+ }