@jinn-network/core 0.1.1 → 0.1.2-canary.15837edd
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/dist/captured-task.d.ts +1 -1
- package/dist/captured-task.js +1 -1
- package/dist/corpus-read/fetch-artifact.d.ts +47 -2
- package/dist/corpus-read/fetch-artifact.js +190 -9
- package/dist/corpus-read/index.d.ts +2 -0
- package/dist/corpus-read/index.js +2 -0
- package/dist/corpus-read/ipfs.d.ts +6 -0
- package/dist/corpus-read/ipfs.js +235 -24
- package/dist/corpus-read/origin-guard.d.ts +75 -0
- package/dist/corpus-read/origin-guard.js +265 -0
- package/dist/corpus-read/pinned-fetch.d.ts +42 -0
- package/dist/corpus-read/pinned-fetch.js +121 -0
- package/dist/envelope.d.ts +2 -2
- package/dist/envelope.js +2 -2
- package/dist/skill-artifact.d.ts +2 -2
- package/dist/skill-artifact.js +2 -2
- package/package.json +4 -4
package/dist/captured-task.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Captured-task schema — the raw, pre-scrub input to the harness capture path.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from the former client harness layer in C2 (#1833):
|
|
5
|
-
* the parse half of capture.ts has no scrub / `
|
|
5
|
+
* the parse half of capture.ts has no scrub / `operator/src` dependency (only
|
|
6
6
|
* zod + the two envelope enums), so it moved into `@jinn-network/core`.
|
|
7
7
|
* C5 subsequently moved the scrub implementation alongside it while leaving
|
|
8
8
|
* capture orchestration in the layer.
|
package/dist/captured-task.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Captured-task schema — the raw, pre-scrub input to the harness capture path.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from the former client harness layer in C2 (#1833):
|
|
5
|
-
* the parse half of capture.ts has no scrub / `
|
|
5
|
+
* the parse half of capture.ts has no scrub / `operator/src` dependency (only
|
|
6
6
|
* zod + the two envelope enums), so it moved into `@jinn-network/core`.
|
|
7
7
|
* C5 subsequently moved the scrub implementation alongside it while leaving
|
|
8
8
|
* capture orchestration in the layer.
|
|
@@ -8,14 +8,59 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Keys artifacts by sha256 (not IPFS CID) — artifacts no longer have IPFS
|
|
10
10
|
* CIDs (post jinn-mono-vy37.1.2).
|
|
11
|
+
*
|
|
12
|
+
* The endpoint is manifest-supplied and therefore attacker-controlled (#1901),
|
|
13
|
+
* so this fetch is both destination-restricted and resource-bounded *before*
|
|
14
|
+
* the hash check in `acquire.ts` ever runs:
|
|
15
|
+
*
|
|
16
|
+
* - every destination — the origin and each redirect hop — must pass
|
|
17
|
+
* `resolvePublicHttpDestination` (see `origin-guard.ts`), and the socket is
|
|
18
|
+
* pinned to the exact address that check approved, so a name whose DNS
|
|
19
|
+
* answer flips between validation and connect cannot escape the policy;
|
|
20
|
+
* - redirects are followed manually and capped;
|
|
21
|
+
* - one deadline bounds the whole chain, including the body read, so a
|
|
22
|
+
* stalled peer cannot hold a worker;
|
|
23
|
+
* - the body is streamed through a byte counter and abandoned the moment it
|
|
24
|
+
* exceeds the cap, so an oversized response is never fully buffered.
|
|
11
25
|
*/
|
|
26
|
+
import { type HostnameResolver } from './origin-guard.js';
|
|
27
|
+
import { type PinnedFetch } from './pinned-fetch.js';
|
|
12
28
|
export type AcquireResult = {
|
|
13
29
|
ok: true;
|
|
14
30
|
content: Buffer;
|
|
15
31
|
} | {
|
|
16
32
|
ok: false;
|
|
17
|
-
|
|
33
|
+
/**
|
|
34
|
+
* `blocked` — destination policy refused the origin or a redirect hop.
|
|
35
|
+
* `too_large` — the response exceeded the byte cap.
|
|
36
|
+
* `timeout` — the response stalled past the deadline.
|
|
37
|
+
*/
|
|
38
|
+
reason: 'not_found' | 'network_error' | 'blocked' | 'too_large' | 'timeout';
|
|
18
39
|
message?: string;
|
|
19
40
|
};
|
|
41
|
+
export interface FetchArtifactOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Injection seam for tests. The default connects over `node:http(s)` with
|
|
44
|
+
* the socket pinned to the guard-approved address; a plain `fetch` fake is
|
|
45
|
+
* accepted here but does not pin, so production must not supply one.
|
|
46
|
+
*/
|
|
47
|
+
fetchImpl?: PinnedFetch;
|
|
48
|
+
/** Injection seam for tests; defaults to `dns.lookup(..., { all: true })`. */
|
|
49
|
+
resolveHostname?: HostnameResolver;
|
|
50
|
+
/**
|
|
51
|
+
* Permit loopback/private destinations, and skip address pinning with them.
|
|
52
|
+
* Off by default and set nowhere in-repo; it exists for an operator running
|
|
53
|
+
* against a local origin, because `operator.publicEndpoint` falls back to
|
|
54
|
+
* `http://localhost:<apiPort>` when unset. Scheme and credential checks
|
|
55
|
+
* still apply. Env: `JINN_CORPUS_ALLOW_PRIVATE_ORIGINS`.
|
|
56
|
+
*/
|
|
57
|
+
allowPrivateDestinations?: boolean;
|
|
58
|
+
/** Byte cap. Env: `JINN_CORPUS_ARTIFACT_MAX_BYTES`. */
|
|
59
|
+
maxBytes?: number;
|
|
60
|
+
/** Whole-operation deadline in ms; `0` disables. Env: `JINN_CORPUS_ARTIFACT_FETCH_TIMEOUT_MS`. */
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
/** Redirect hop cap. Env: `JINN_CORPUS_ARTIFACT_MAX_REDIRECTS`. */
|
|
63
|
+
maxRedirects?: number;
|
|
64
|
+
}
|
|
20
65
|
export declare function buildArtifactUrl(endpoint: string, sha256: string): string;
|
|
21
|
-
export declare function fetchArtifactContent(endpoint: string, sha256: string): Promise<AcquireResult>;
|
|
66
|
+
export declare function fetchArtifactContent(endpoint: string, sha256: string, options?: FetchArtifactOptions): Promise<AcquireResult>;
|
|
@@ -8,25 +8,206 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Keys artifacts by sha256 (not IPFS CID) — artifacts no longer have IPFS
|
|
10
10
|
* CIDs (post jinn-mono-vy37.1.2).
|
|
11
|
+
*
|
|
12
|
+
* The endpoint is manifest-supplied and therefore attacker-controlled (#1901),
|
|
13
|
+
* so this fetch is both destination-restricted and resource-bounded *before*
|
|
14
|
+
* the hash check in `acquire.ts` ever runs:
|
|
15
|
+
*
|
|
16
|
+
* - every destination — the origin and each redirect hop — must pass
|
|
17
|
+
* `resolvePublicHttpDestination` (see `origin-guard.ts`), and the socket is
|
|
18
|
+
* pinned to the exact address that check approved, so a name whose DNS
|
|
19
|
+
* answer flips between validation and connect cannot escape the policy;
|
|
20
|
+
* - redirects are followed manually and capped;
|
|
21
|
+
* - one deadline bounds the whole chain, including the body read, so a
|
|
22
|
+
* stalled peer cannot hold a worker;
|
|
23
|
+
* - the body is streamed through a byte counter and abandoned the moment it
|
|
24
|
+
* exceeds the cap, so an oversized response is never fully buffered.
|
|
25
|
+
*/
|
|
26
|
+
import { resolvePublicHttpDestination, ProhibitedDestinationError, } from './origin-guard.js';
|
|
27
|
+
import { pinnedFetch } from './pinned-fetch.js';
|
|
28
|
+
/** 32 MiB. Artifacts are solution/evidence payloads, not disk images. */
|
|
29
|
+
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
31
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
32
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
33
|
+
class TimeoutError extends Error {
|
|
34
|
+
}
|
|
35
|
+
class TooLargeError extends Error {
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `minimum` is 0 where the bound reads `0` as "disabled" (the timeout, and a
|
|
39
|
+
* redirect cap of zero meaning "follow none"), and 1 for the byte cap, where
|
|
40
|
+
* zero would not disable anything — it would reject every artifact as
|
|
41
|
+
* `too_large`. A foot-gun that silently stops all acquisition is worse than
|
|
42
|
+
* ignoring the value, so an out-of-range setting falls back to the default.
|
|
11
43
|
*/
|
|
44
|
+
function envInteger(name, fallback, minimum = 0) {
|
|
45
|
+
const raw = process.env[name];
|
|
46
|
+
if (raw === undefined || raw.trim() === '')
|
|
47
|
+
return fallback;
|
|
48
|
+
const parsed = Number(raw);
|
|
49
|
+
return Number.isInteger(parsed) && parsed >= minimum ? parsed : fallback;
|
|
50
|
+
}
|
|
51
|
+
function envFlag(name) {
|
|
52
|
+
const raw = process.env[name];
|
|
53
|
+
if (raw === undefined)
|
|
54
|
+
return false;
|
|
55
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
56
|
+
}
|
|
12
57
|
export function buildArtifactUrl(endpoint, sha256) {
|
|
13
58
|
return `${endpoint.replace(/\/$/, '')}/v1/artifacts/${sha256}/content`;
|
|
14
59
|
}
|
|
15
|
-
|
|
16
|
-
|
|
60
|
+
/** Stream `response` into a Buffer, abandoning it the moment it exceeds `maxBytes`. */
|
|
61
|
+
async function readBounded(response, maxBytes) {
|
|
62
|
+
const declared = response.headers.get('content-length');
|
|
63
|
+
if (declared !== null && Number(declared) > maxBytes) {
|
|
64
|
+
await response.body?.cancel().catch(() => { });
|
|
65
|
+
throw new TooLargeError(`content-length ${declared} exceeds the ${maxBytes}-byte cap`);
|
|
66
|
+
}
|
|
67
|
+
// A null body carries no bytes. We deliberately do not fall back to
|
|
68
|
+
// `arrayBuffer()` here: that materializes the whole response before any
|
|
69
|
+
// size check, which is the exact behavior the byte cap exists to prevent.
|
|
70
|
+
if (!response.body)
|
|
71
|
+
return Buffer.alloc(0);
|
|
72
|
+
const reader = response.body.getReader();
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let total = 0;
|
|
75
|
+
try {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done)
|
|
79
|
+
break;
|
|
80
|
+
if (!value)
|
|
81
|
+
continue;
|
|
82
|
+
total += value.byteLength;
|
|
83
|
+
if (total > maxBytes) {
|
|
84
|
+
throw new TooLargeError(`response exceeds the ${maxBytes}-byte cap`);
|
|
85
|
+
}
|
|
86
|
+
chunks.push(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
await reader.cancel().catch(() => { });
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
return Buffer.concat(chunks, total);
|
|
94
|
+
}
|
|
95
|
+
export async function fetchArtifactContent(endpoint, sha256, options = {}) {
|
|
96
|
+
const fetchImpl = options.fetchImpl ?? pinnedFetch;
|
|
97
|
+
const maxBytes = options.maxBytes
|
|
98
|
+
?? envInteger('JINN_CORPUS_ARTIFACT_MAX_BYTES', DEFAULT_MAX_BYTES, 1);
|
|
99
|
+
const timeoutMs = options.timeoutMs
|
|
100
|
+
?? envInteger('JINN_CORPUS_ARTIFACT_FETCH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
|
|
101
|
+
const maxRedirects = options.maxRedirects
|
|
102
|
+
?? envInteger('JINN_CORPUS_ARTIFACT_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS);
|
|
103
|
+
const guard = {
|
|
104
|
+
resolveHostname: options.resolveHostname,
|
|
105
|
+
allowPrivateDestinations: options.allowPrivateDestinations ?? envFlag('JINN_CORPUS_ALLOW_PRIVATE_ORIGINS'),
|
|
106
|
+
};
|
|
107
|
+
const raw = buildArtifactUrl(endpoint, sha256);
|
|
108
|
+
let target;
|
|
17
109
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
110
|
+
target = new URL(raw);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, reason: 'blocked', message: `artifact endpoint is not a valid URL: ${raw}` };
|
|
114
|
+
}
|
|
115
|
+
// One controller and one timer bound the whole chain — connect, every
|
|
116
|
+
// redirect hop, and the body read. The timeout both aborts the transport
|
|
117
|
+
// and loses the race, so a transport that ignores the signal still cannot
|
|
118
|
+
// leave the caller waiting past the deadline.
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
let timer;
|
|
121
|
+
const expiry = timeoutMs > 0
|
|
122
|
+
? new Promise((_resolve, reject) => {
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
controller.abort();
|
|
125
|
+
reject(new TimeoutError(`artifact fetch timed out after ${timeoutMs}ms`));
|
|
126
|
+
}, timeoutMs);
|
|
127
|
+
})
|
|
128
|
+
: undefined;
|
|
129
|
+
// Attach a sink so the rejection is never unhandled — under Node's default
|
|
130
|
+
// `--unhandled-rejections=throw`, a timer firing before the first `bounded()`
|
|
131
|
+
// call would otherwise kill the process outright.
|
|
132
|
+
expiry?.catch(() => { });
|
|
133
|
+
const bounded = (work) => expiry === undefined ? work : Promise.race([work, expiry]);
|
|
134
|
+
const chain = async () => {
|
|
135
|
+
for (let hop = 0;; hop += 1) {
|
|
136
|
+
// Bounded like every other await: the guard resolves DNS for an
|
|
137
|
+
// attacker-supplied name, so a nameserver that simply never answers
|
|
138
|
+
// must not be able to hold the caller past the deadline.
|
|
139
|
+
const pin = await bounded(resolvePublicHttpDestination(target, guard));
|
|
140
|
+
const response = await bounded(fetchImpl(target, {
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
...(pin === null ? {} : { pinnedAddresses: pin.addresses }),
|
|
143
|
+
}));
|
|
144
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
145
|
+
const location = response.headers.get('location');
|
|
146
|
+
if (location === null) {
|
|
147
|
+
await response.body?.cancel().catch(() => { });
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: 'network_error',
|
|
151
|
+
message: `HTTP ${response.status} without a Location header for ${target.href}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (hop >= maxRedirects) {
|
|
155
|
+
await response.body?.cancel().catch(() => { });
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: 'blocked',
|
|
159
|
+
message: `artifact fetch exceeded ${maxRedirects} redirects at ${target.href}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let next;
|
|
163
|
+
try {
|
|
164
|
+
next = new URL(location, target);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
await response.body?.cancel().catch(() => { });
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: 'blocked',
|
|
171
|
+
message: `redirect Location is not a valid URL: ${location}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
await response.body?.cancel().catch(() => { });
|
|
175
|
+
target = next;
|
|
176
|
+
continue;
|
|
22
177
|
}
|
|
23
|
-
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
await response.body?.cancel().catch(() => { });
|
|
180
|
+
if (response.status === 404) {
|
|
181
|
+
return { ok: false, reason: 'not_found', message: `HTTP 404 for ${target.href}` };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: 'network_error',
|
|
186
|
+
message: `HTTP ${response.status} for ${target.href}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, content: await bounded(readBounded(response, maxBytes)) };
|
|
24
190
|
}
|
|
25
|
-
|
|
26
|
-
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
return await chain();
|
|
27
194
|
}
|
|
28
195
|
catch (err) {
|
|
196
|
+
if (err instanceof ProhibitedDestinationError) {
|
|
197
|
+
return { ok: false, reason: 'blocked', message: err.message };
|
|
198
|
+
}
|
|
199
|
+
if (err instanceof TimeoutError) {
|
|
200
|
+
return { ok: false, reason: 'timeout', message: err.message };
|
|
201
|
+
}
|
|
202
|
+
if (err instanceof TooLargeError) {
|
|
203
|
+
return { ok: false, reason: 'too_large', message: err.message };
|
|
204
|
+
}
|
|
29
205
|
const message = err instanceof Error ? err.message : String(err);
|
|
30
206
|
return { ok: false, reason: 'network_error', message };
|
|
31
207
|
}
|
|
208
|
+
finally {
|
|
209
|
+
if (timer !== undefined)
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
controller.abort();
|
|
212
|
+
}
|
|
32
213
|
}
|
|
@@ -6,5 +6,7 @@ export * from './fetch-artifact.js';
|
|
|
6
6
|
export * from './fetch.js';
|
|
7
7
|
export * from './http-discovery.js';
|
|
8
8
|
export * from './ipfs.js';
|
|
9
|
+
export * from './origin-guard.js';
|
|
10
|
+
export * from './pinned-fetch.js';
|
|
9
11
|
export * from './route-resolver.js';
|
|
10
12
|
export * from './types.js';
|
|
@@ -6,5 +6,7 @@ export * from './fetch-artifact.js';
|
|
|
6
6
|
export * from './fetch.js';
|
|
7
7
|
export * from './http-discovery.js';
|
|
8
8
|
export * from './ipfs.js';
|
|
9
|
+
export * from './origin-guard.js';
|
|
10
|
+
export * from './pinned-fetch.js';
|
|
9
11
|
export * from './route-resolver.js';
|
|
10
12
|
export * from './types.js';
|
|
@@ -12,3 +12,9 @@ export type FetchFromIpfsOptions = {
|
|
|
12
12
|
};
|
|
13
13
|
/** Read-only multi-codec, primary-plus-fallback IPFS JSON fetch. */
|
|
14
14
|
export declare function fetchFromIpfs(gatewayUrl: string, cid: string, opts?: FetchFromIpfsOptions): Promise<unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* Read-only multi-codec, primary-plus-fallback IPFS fetch returning the exact
|
|
17
|
+
* bytes stored at the CID — no JSON parse/re-encode roundtrip. Use this
|
|
18
|
+
* whenever the bytes will be hashed, or whenever the content is not JSON.
|
|
19
|
+
*/
|
|
20
|
+
export declare function fetchBytesFromIpfs(gatewayUrl: string, cid: string, opts?: FetchFromIpfsOptions): Promise<Uint8Array>;
|
package/dist/corpus-read/ipfs.js
CHANGED
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
const IPFS_FETCH_TIMEOUT_MS = 15_000;
|
|
2
|
+
/**
|
|
3
|
+
* Bound on one whole `fetchFromIpfs` / `fetchBytesFromIpfs` call. The
|
|
4
|
+
* per-attempt timer above is re-armed for every gateway x CID candidate, so
|
|
5
|
+
* without this a single call could legitimately run for the product of the
|
|
6
|
+
* two. Every candidate is still attempted: the per-attempt timer is clamped
|
|
7
|
+
* to an equal share of whatever budget remains.
|
|
8
|
+
*/
|
|
9
|
+
const IPFS_TOTAL_FETCH_TIMEOUT_MS = 45_000;
|
|
10
|
+
/**
|
|
11
|
+
* Cap on any single gateway response, JSON or raw. Corpus manifests and
|
|
12
|
+
* donation artifacts are JSON envelopes orders of magnitude smaller than this
|
|
13
|
+
* (#3410); the raw-bytes path added in #3438 also carries source-bundle files
|
|
14
|
+
* and sealed documents, which are larger but nowhere near this. A response
|
|
15
|
+
* above it is hostile or broken, and buffering it would exhaust memory — so
|
|
16
|
+
* size this against the largest legitimate *source file*, not against the
|
|
17
|
+
* JSON envelopes alone.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_IPFS_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_IPFS_REDIRECT_HOPS = 3;
|
|
21
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
2
22
|
const FALLBACK_IPFS_GATEWAY_BASE = 'https://ipfs.io/ipfs/';
|
|
3
23
|
export function normalizeIpfsGatewayBase(gatewayUrl) {
|
|
4
24
|
let normalized = gatewayUrl.trim();
|
|
5
25
|
if (normalized === '')
|
|
6
26
|
normalized = 'https://gateway.autonolas.tech';
|
|
27
|
+
// Drop any userinfo at the source. `fetch` rejects a credentialed URL
|
|
28
|
+
// outright, and its own error message quotes the URL back — so a gateway
|
|
29
|
+
// configured Infura-style would otherwise put its secret into every
|
|
30
|
+
// aggregated fetch error, which callers log.
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(normalized);
|
|
33
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
34
|
+
parsed.username = '';
|
|
35
|
+
parsed.password = '';
|
|
36
|
+
normalized = parsed.toString();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Not an absolute URL; leave it to the caller's own failure path.
|
|
41
|
+
}
|
|
7
42
|
normalized = normalized.replace(/\/+$/, '');
|
|
8
43
|
if (!normalized.toLowerCase().endsWith('/ipfs'))
|
|
9
44
|
normalized = `${normalized}/ipfs`;
|
|
@@ -29,15 +64,145 @@ export function buildIpfsFetchCidPathCandidates(cidOrPath) {
|
|
|
29
64
|
}
|
|
30
65
|
return [value];
|
|
31
66
|
}
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
function isHostInGatewayFamily(host, gatewayHost) {
|
|
68
|
+
const candidate = host.toLowerCase();
|
|
69
|
+
const gateway = gatewayHost.toLowerCase();
|
|
70
|
+
return candidate === gateway || candidate.endsWith(`.${gateway}`);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A gateway may legitimately redirect the path form of a CID to its subdomain
|
|
74
|
+
* form, so hops are allowed inside the configured gateway's host family. They
|
|
75
|
+
* are never allowed to leave it, to reach a different port on it, to downgrade
|
|
76
|
+
* the transport, or to smuggle credentials.
|
|
77
|
+
*/
|
|
78
|
+
function assertRedirectAllowed(next, current, gateway) {
|
|
79
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
80
|
+
throw new Error(`IPFS redirect uses an unsupported scheme (${next.protocol})`);
|
|
81
|
+
}
|
|
82
|
+
if (current.protocol === 'https:' && next.protocol === 'http:') {
|
|
83
|
+
throw new Error('IPFS redirect downgrades https to http');
|
|
84
|
+
}
|
|
85
|
+
if (next.username !== '' || next.password !== '') {
|
|
86
|
+
throw new Error('IPFS redirect carries embedded credentials');
|
|
87
|
+
}
|
|
88
|
+
if (!isHostInGatewayFamily(next.hostname, gateway.hostname)) {
|
|
89
|
+
throw new Error(`IPFS redirect leaves the configured gateway (${next.hostname})`);
|
|
90
|
+
}
|
|
91
|
+
// Host family alone would let a self-hosted gateway (`http://127.0.0.1:8080/ipfs/`)
|
|
92
|
+
// pivot onto any other service on the same host. `URL.port` is '' for the
|
|
93
|
+
// scheme default, so the comparison is already normalized.
|
|
94
|
+
if (next.port !== gateway.port) {
|
|
95
|
+
throw new Error(`IPFS redirect changes the gateway port (${next.port === '' ? 'default' : next.port})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Location for an error message, with any configured gateway credentials dropped. */
|
|
99
|
+
function displayUrl(url) {
|
|
100
|
+
return `${url.origin}${url.pathname}`.slice(0, 100);
|
|
101
|
+
}
|
|
102
|
+
async function discardBody(response) {
|
|
103
|
+
try {
|
|
104
|
+
await response.body?.cancel();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A body that refuses to cancel is not worth failing the request over.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Read a response body as bytes, refusing anything past the byte cap. */
|
|
111
|
+
async function readBoundedBytes(response) {
|
|
112
|
+
const declared = Number(response.headers.get('content-length'));
|
|
113
|
+
if (Number.isFinite(declared) && declared > MAX_IPFS_RESPONSE_BYTES) {
|
|
114
|
+
await discardBody(response);
|
|
115
|
+
throw new Error(`IPFS response exceeds the ${MAX_IPFS_RESPONSE_BYTES}-byte cap ` +
|
|
116
|
+
`(content-length ${declared})`);
|
|
117
|
+
}
|
|
118
|
+
const body = response.body;
|
|
119
|
+
if (!body)
|
|
120
|
+
return new Uint8Array(0);
|
|
121
|
+
const reader = body.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
try {
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done)
|
|
128
|
+
break;
|
|
129
|
+
if (!value)
|
|
130
|
+
continue;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > MAX_IPFS_RESPONSE_BYTES) {
|
|
133
|
+
throw new Error(`IPFS response exceeds the ${MAX_IPFS_RESPONSE_BYTES}-byte cap`);
|
|
134
|
+
}
|
|
135
|
+
chunks.push(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
try {
|
|
140
|
+
await reader.cancel();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Already terminal; the read result (or throw) above is what matters.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const joined = new Uint8Array(total);
|
|
147
|
+
let offset = 0;
|
|
148
|
+
for (const chunk of chunks) {
|
|
149
|
+
joined.set(chunk, offset);
|
|
150
|
+
offset += chunk.byteLength;
|
|
151
|
+
}
|
|
152
|
+
return joined;
|
|
153
|
+
}
|
|
154
|
+
/** Read a response body as text, refusing anything past the byte cap. */
|
|
155
|
+
async function readBoundedText(response) {
|
|
156
|
+
const joined = await readBoundedBytes(response);
|
|
157
|
+
try {
|
|
158
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(joined);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new Error('IPFS response is not valid UTF-8');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Request `url`, resolving redirects here rather than in `fetch`, so every hop
|
|
166
|
+
* is revalidated against the configured gateway before it is requested.
|
|
167
|
+
* Returns the first non-redirect, ok response; its body is still unread.
|
|
168
|
+
*/
|
|
169
|
+
async function fetchThroughGateway(url, signal) {
|
|
170
|
+
const gateway = url;
|
|
171
|
+
let current = new URL(url);
|
|
172
|
+
for (let hop = 0;; hop += 1) {
|
|
173
|
+
const response = await fetch(current, { method: 'GET', redirect: 'manual', signal });
|
|
174
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
175
|
+
await discardBody(response);
|
|
176
|
+
if (hop >= MAX_IPFS_REDIRECT_HOPS) {
|
|
177
|
+
throw new Error(`IPFS fetch exceeded ${MAX_IPFS_REDIRECT_HOPS} redirects`);
|
|
178
|
+
}
|
|
179
|
+
const location = response.headers.get('location');
|
|
180
|
+
if (location === null || location.trim() === '') {
|
|
181
|
+
throw new Error(`IPFS gateway returned ${response.status} without a Location header`);
|
|
182
|
+
}
|
|
183
|
+
let next;
|
|
184
|
+
try {
|
|
185
|
+
next = new URL(location, current);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error('IPFS redirect Location is not a valid URL');
|
|
189
|
+
}
|
|
190
|
+
assertRedirectAllowed(next, current, gateway);
|
|
191
|
+
current = next;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
await discardBody(response);
|
|
196
|
+
throw new Error(`IPFS fetch failed: ${response.status} ${response.statusText} ` +
|
|
197
|
+
`(${displayUrl(current)}…)`);
|
|
198
|
+
}
|
|
199
|
+
return response;
|
|
36
200
|
}
|
|
201
|
+
}
|
|
202
|
+
async function fetchJson(url, signal) {
|
|
203
|
+
const response = await fetchThroughGateway(url, signal);
|
|
37
204
|
const contentType = response.headers.get('content-type') ?? '';
|
|
38
|
-
|
|
39
|
-
return response.json();
|
|
40
|
-
const text = await response.text();
|
|
205
|
+
const text = await readBoundedText(response);
|
|
41
206
|
try {
|
|
42
207
|
return JSON.parse(text);
|
|
43
208
|
}
|
|
@@ -45,6 +210,9 @@ async function fetchJson(url, signal) {
|
|
|
45
210
|
throw new Error(`IPFS response is not JSON (content-type: ${contentType || 'none'})`);
|
|
46
211
|
}
|
|
47
212
|
}
|
|
213
|
+
async function fetchBytes(url, signal) {
|
|
214
|
+
return readBoundedBytes(await fetchThroughGateway(url, signal));
|
|
215
|
+
}
|
|
48
216
|
function resolveFallbackGatewayBases(opts) {
|
|
49
217
|
if (opts?.fallbackGatewayBase === false)
|
|
50
218
|
return [];
|
|
@@ -53,29 +221,72 @@ function resolveFallbackGatewayBases(opts) {
|
|
|
53
221
|
}
|
|
54
222
|
return [['fallback', FALLBACK_IPFS_GATEWAY_BASE]];
|
|
55
223
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Run every gateway x CID candidate through `read`, under one whole-operation
|
|
226
|
+
* deadline. Shared by the JSON and raw-bytes entry points so both inherit the
|
|
227
|
+
* same redirect revalidation, byte cap, and deadline (#3438).
|
|
228
|
+
*/
|
|
229
|
+
async function fetchCandidatesFromIpfs(gatewayUrl, cid, opts, read, failureLabel) {
|
|
58
230
|
const primary = normalizeIpfsGatewayBase(gatewayUrl);
|
|
59
231
|
const gateways = [
|
|
60
232
|
['primary', primary],
|
|
61
233
|
...resolveFallbackGatewayBases(opts),
|
|
62
234
|
];
|
|
63
|
-
const
|
|
235
|
+
const attempts = [];
|
|
64
236
|
for (const cidPath of buildIpfsFetchCidPathCandidates(cid)) {
|
|
65
|
-
for (const [name, baseUrl] of gateways)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
237
|
+
for (const [name, baseUrl] of gateways)
|
|
238
|
+
attempts.push([name, `${baseUrl}${cidPath}`]);
|
|
239
|
+
}
|
|
240
|
+
const errors = [];
|
|
241
|
+
const deadline = Date.now() + IPFS_TOTAL_FETCH_TIMEOUT_MS;
|
|
242
|
+
for (let index = 0; index < attempts.length; index += 1) {
|
|
243
|
+
const [name, url] = attempts[index];
|
|
244
|
+
const remainingMs = deadline - Date.now();
|
|
245
|
+
if (remainingMs <= 0) {
|
|
246
|
+
errors.push(`whole-operation timeout after ${IPFS_TOTAL_FETCH_TIMEOUT_MS}ms`);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
// Share what is left of the budget across the candidates still to try, so a
|
|
250
|
+
// run of slow early candidates cannot starve a later one that would have
|
|
251
|
+
// succeeded. Without this the whole-operation bound would silently narrow
|
|
252
|
+
// the candidate matrix instead of only bounding it.
|
|
253
|
+
const attemptMs = Math.min(IPFS_FETCH_TIMEOUT_MS, Math.ceil(remainingMs / (attempts.length - index)));
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
const timer = setTimeout(() => controller.abort(), attemptMs);
|
|
256
|
+
// Parsed once here rather than inside the catch, so a URL this candidate
|
|
257
|
+
// cannot even parse is reported as a candidate failure instead of escaping
|
|
258
|
+
// as a bare TypeError that discards the other candidates' errors.
|
|
259
|
+
let target;
|
|
260
|
+
try {
|
|
261
|
+
target = new URL(url);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
errors.push(`${name}: candidate URL could not be parsed`);
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
return await read(target, controller.signal);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
errors.push(`${name}:${displayUrl(target)}: ` +
|
|
273
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
clearTimeout(timer);
|
|
78
277
|
}
|
|
79
278
|
}
|
|
80
|
-
throw new Error(
|
|
279
|
+
throw new Error(`${failureLabel}: ${errors.join(' | ')}`);
|
|
280
|
+
}
|
|
281
|
+
/** Read-only multi-codec, primary-plus-fallback IPFS JSON fetch. */
|
|
282
|
+
export async function fetchFromIpfs(gatewayUrl, cid, opts) {
|
|
283
|
+
return fetchCandidatesFromIpfs(gatewayUrl, cid, opts, fetchJson, 'IPFS JSON fetch failed after all candidates');
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Read-only multi-codec, primary-plus-fallback IPFS fetch returning the exact
|
|
287
|
+
* bytes stored at the CID — no JSON parse/re-encode roundtrip. Use this
|
|
288
|
+
* whenever the bytes will be hashed, or whenever the content is not JSON.
|
|
289
|
+
*/
|
|
290
|
+
export async function fetchBytesFromIpfs(gatewayUrl, cid, opts) {
|
|
291
|
+
return fetchCandidatesFromIpfs(gatewayUrl, cid, opts, fetchBytes, 'IPFS raw bytes fetch failed after all candidates');
|
|
81
292
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destination policy for manifest-supplied artifact origins (#1901).
|
|
3
|
+
*
|
|
4
|
+
* A corpus manifest is attacker-controlled input: `artifact.access.endpoint`
|
|
5
|
+
* arrives from whoever published the envelope. Fetching it unguarded turns
|
|
6
|
+
* every daemon into an SSRF proxy for loopback, RFC1918, link-local, and
|
|
7
|
+
* cloud metadata services. This module is the single place that decides
|
|
8
|
+
* whether a destination is allowed to be contacted at all.
|
|
9
|
+
*
|
|
10
|
+
* The policy is deny-by-default: a destination is public only if we can
|
|
11
|
+
* positively classify every address behind it as public.
|
|
12
|
+
*
|
|
13
|
+
* Validation alone is not enough. Revalidating a hostname and then handing
|
|
14
|
+
* the *name* to the transport lets an attacker who controls its DNS answer
|
|
15
|
+
* one address to us and a different one to the socket (rebinding), which
|
|
16
|
+
* bypasses the check entirely. So this module does not merely approve a
|
|
17
|
+
* destination — it returns the exact address it approved, and the transport
|
|
18
|
+
* pins the connection to that address. The value we classify is the value we
|
|
19
|
+
* connect to.
|
|
20
|
+
*/
|
|
21
|
+
/** Why a destination was refused, or `'public'` when it is allowed. */
|
|
22
|
+
export type AddressClass = 'public' | ProhibitedReason;
|
|
23
|
+
export type ProhibitedReason = 'loopback' | 'private' | 'link-local' | 'multicast' | 'unspecified' | 'broadcast' | 'reserved' | 'carrier-nat' | 'unique-local' | 'documentation' | 'unparsable';
|
|
24
|
+
export declare class ProhibitedDestinationError extends Error {
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
readonly reason: ProhibitedReason | 'scheme' | 'credentials' | 'malformed-url' | 'redirect-cap';
|
|
27
|
+
constructor(detail: string, reason: ProhibitedReason | 'scheme' | 'credentials' | 'malformed-url' | 'redirect-cap');
|
|
28
|
+
}
|
|
29
|
+
/** Classify a dotted-quad IPv4 address against the prohibited-range table. */
|
|
30
|
+
export declare function classifyIpv4(ip: string): AddressClass;
|
|
31
|
+
/**
|
|
32
|
+
* Classify an IPv6 address. Embedded-IPv4 forms (IPv4-mapped, NAT64, 6to4)
|
|
33
|
+
* are unwrapped and classified as IPv4 so `::ffff:127.0.0.1` cannot smuggle
|
|
34
|
+
* loopback past the IPv4 table.
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifyIpv6(ip: string): AddressClass;
|
|
37
|
+
/** Classify any IP literal. Anything we cannot parse is refused. */
|
|
38
|
+
export declare function classifyIpAddress(ip: string): AddressClass;
|
|
39
|
+
/** Resolve a hostname to every A/AAAA address the resolver knows. */
|
|
40
|
+
export type HostnameResolver = (hostname: string) => Promise<string[]>;
|
|
41
|
+
export interface OriginGuardOptions {
|
|
42
|
+
/** Injection seam for tests; defaults to `dns.lookup(..., { all: true })`. */
|
|
43
|
+
resolveHostname?: HostnameResolver;
|
|
44
|
+
/**
|
|
45
|
+
* Escape hatch for an operator pointing at a local origin, where
|
|
46
|
+
* `publicEndpoint` legitimately defaults to `http://localhost:<apiPort>`.
|
|
47
|
+
* Off unless explicitly enabled — the default is fail-closed. It waives
|
|
48
|
+
* only the address policy; scheme and credential checks still run.
|
|
49
|
+
*/
|
|
50
|
+
allowPrivateDestinations?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** The exact destinations the guard approved, for the transport to pin to. */
|
|
53
|
+
export interface PinnedDestination {
|
|
54
|
+
/**
|
|
55
|
+
* Every address the connection may use, in resolver order. All of them
|
|
56
|
+
* passed the policy, so the transport is free to fail over between them —
|
|
57
|
+
* pinning to just the first would break dual-stack and multi-A origins.
|
|
58
|
+
*/
|
|
59
|
+
readonly addresses: ReadonlyArray<{
|
|
60
|
+
readonly address: string;
|
|
61
|
+
readonly family: 4 | 6;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Validate one destination and return the address it is allowed to reach.
|
|
66
|
+
*
|
|
67
|
+
* Throws `ProhibitedDestinationError` unless the URL is a credential-free
|
|
68
|
+
* public `http:`/`https:` destination. On success the caller MUST connect to
|
|
69
|
+
* the returned address rather than re-resolving the hostname — re-resolving
|
|
70
|
+
* reopens the rebinding window this function exists to close.
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` only when `allowPrivateDestinations` is set, where there is
|
|
73
|
+
* no policy to pin to.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolvePublicHttpDestination(url: URL, options?: OriginGuardOptions): Promise<PinnedDestination | null>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destination policy for manifest-supplied artifact origins (#1901).
|
|
3
|
+
*
|
|
4
|
+
* A corpus manifest is attacker-controlled input: `artifact.access.endpoint`
|
|
5
|
+
* arrives from whoever published the envelope. Fetching it unguarded turns
|
|
6
|
+
* every daemon into an SSRF proxy for loopback, RFC1918, link-local, and
|
|
7
|
+
* cloud metadata services. This module is the single place that decides
|
|
8
|
+
* whether a destination is allowed to be contacted at all.
|
|
9
|
+
*
|
|
10
|
+
* The policy is deny-by-default: a destination is public only if we can
|
|
11
|
+
* positively classify every address behind it as public.
|
|
12
|
+
*
|
|
13
|
+
* Validation alone is not enough. Revalidating a hostname and then handing
|
|
14
|
+
* the *name* to the transport lets an attacker who controls its DNS answer
|
|
15
|
+
* one address to us and a different one to the socket (rebinding), which
|
|
16
|
+
* bypasses the check entirely. So this module does not merely approve a
|
|
17
|
+
* destination — it returns the exact address it approved, and the transport
|
|
18
|
+
* pins the connection to that address. The value we classify is the value we
|
|
19
|
+
* connect to.
|
|
20
|
+
*/
|
|
21
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
22
|
+
import { lookup } from 'node:dns/promises';
|
|
23
|
+
export class ProhibitedDestinationError extends Error {
|
|
24
|
+
detail;
|
|
25
|
+
reason;
|
|
26
|
+
constructor(detail, reason) {
|
|
27
|
+
super(detail);
|
|
28
|
+
this.detail = detail;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
this.name = 'ProhibitedDestinationError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function parseIpv4(ip) {
|
|
34
|
+
const parts = ip.split('.');
|
|
35
|
+
if (parts.length !== 4)
|
|
36
|
+
return null;
|
|
37
|
+
const octets = [];
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
if (!/^\d{1,3}$/u.test(part))
|
|
40
|
+
return null;
|
|
41
|
+
const value = Number(part);
|
|
42
|
+
if (value > 255)
|
|
43
|
+
return null;
|
|
44
|
+
octets.push(value);
|
|
45
|
+
}
|
|
46
|
+
return octets;
|
|
47
|
+
}
|
|
48
|
+
/** Classify a dotted-quad IPv4 address against the prohibited-range table. */
|
|
49
|
+
export function classifyIpv4(ip) {
|
|
50
|
+
const octets = parseIpv4(ip);
|
|
51
|
+
if (!octets)
|
|
52
|
+
return 'unparsable';
|
|
53
|
+
const [a, b, c] = octets;
|
|
54
|
+
if (a === 0)
|
|
55
|
+
return 'unspecified'; // 0.0.0.0/8 "this network"
|
|
56
|
+
if (a === 10)
|
|
57
|
+
return 'private'; // RFC1918
|
|
58
|
+
if (a === 127)
|
|
59
|
+
return 'loopback'; // RFC1122
|
|
60
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
61
|
+
return 'carrier-nat'; // RFC6598 100.64/10
|
|
62
|
+
if (a === 169 && b === 254)
|
|
63
|
+
return 'link-local'; // incl. 169.254.169.254 metadata
|
|
64
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
65
|
+
return 'private'; // RFC1918
|
|
66
|
+
if (a === 192 && b === 0 && c === 0)
|
|
67
|
+
return 'reserved'; // IETF protocol assignments
|
|
68
|
+
if (a === 192 && b === 0 && c === 2)
|
|
69
|
+
return 'documentation'; // TEST-NET-1
|
|
70
|
+
if (a === 192 && b === 88 && c === 99)
|
|
71
|
+
return 'reserved'; // 6to4 relay anycast
|
|
72
|
+
if (a === 192 && b === 168)
|
|
73
|
+
return 'private'; // RFC1918
|
|
74
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
75
|
+
return 'reserved'; // benchmarking
|
|
76
|
+
if (a === 198 && b === 51 && c === 100)
|
|
77
|
+
return 'documentation'; // TEST-NET-2
|
|
78
|
+
if (a === 203 && b === 0 && c === 113)
|
|
79
|
+
return 'documentation'; // TEST-NET-3
|
|
80
|
+
if (a >= 224 && a <= 239)
|
|
81
|
+
return 'multicast';
|
|
82
|
+
if (ip === '255.255.255.255')
|
|
83
|
+
return 'broadcast';
|
|
84
|
+
if (a >= 240)
|
|
85
|
+
return 'reserved'; // 240/4 future use
|
|
86
|
+
return 'public';
|
|
87
|
+
}
|
|
88
|
+
/** Expand an IPv6 literal (with optional `::` and trailing IPv4) to 16 bytes. */
|
|
89
|
+
function parseIpv6(ip) {
|
|
90
|
+
const zoneless = ip.split('%')[0];
|
|
91
|
+
const halves = zoneless.split('::');
|
|
92
|
+
if (halves.length > 2)
|
|
93
|
+
return null;
|
|
94
|
+
const expand = (segment) => {
|
|
95
|
+
if (segment === '')
|
|
96
|
+
return [];
|
|
97
|
+
const bytes = [];
|
|
98
|
+
const groups = segment.split(':');
|
|
99
|
+
for (let i = 0; i < groups.length; i += 1) {
|
|
100
|
+
const group = groups[i];
|
|
101
|
+
if (group.includes('.')) {
|
|
102
|
+
// Trailing dotted-quad form is only legal in the last position.
|
|
103
|
+
if (i !== groups.length - 1)
|
|
104
|
+
return null;
|
|
105
|
+
const octets = parseIpv4(group);
|
|
106
|
+
if (!octets)
|
|
107
|
+
return null;
|
|
108
|
+
bytes.push(...octets);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!/^[0-9a-fA-F]{1,4}$/u.test(group))
|
|
112
|
+
return null;
|
|
113
|
+
const value = Number.parseInt(group, 16);
|
|
114
|
+
bytes.push((value >> 8) & 0xff, value & 0xff);
|
|
115
|
+
}
|
|
116
|
+
return bytes;
|
|
117
|
+
};
|
|
118
|
+
const head = expand(halves[0]);
|
|
119
|
+
const tail = halves.length === 2 ? expand(halves[1]) : [];
|
|
120
|
+
if (head === null || tail === null)
|
|
121
|
+
return null;
|
|
122
|
+
if (halves.length === 1)
|
|
123
|
+
return head.length === 16 ? head : null;
|
|
124
|
+
const gap = 16 - head.length - tail.length;
|
|
125
|
+
if (gap < 0)
|
|
126
|
+
return null;
|
|
127
|
+
return [...head, ...new Array(gap).fill(0), ...tail];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Classify an IPv6 address. Embedded-IPv4 forms (IPv4-mapped, NAT64, 6to4)
|
|
131
|
+
* are unwrapped and classified as IPv4 so `::ffff:127.0.0.1` cannot smuggle
|
|
132
|
+
* loopback past the IPv4 table.
|
|
133
|
+
*/
|
|
134
|
+
export function classifyIpv6(ip) {
|
|
135
|
+
const bytes = parseIpv6(ip);
|
|
136
|
+
if (!bytes)
|
|
137
|
+
return 'unparsable';
|
|
138
|
+
const dotted = (offset) => bytes.slice(offset, offset + 4).join('.');
|
|
139
|
+
if (bytes.every((byte) => byte === 0))
|
|
140
|
+
return 'unspecified';
|
|
141
|
+
if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1)
|
|
142
|
+
return 'loopback';
|
|
143
|
+
// ::ffff:0:0/96 — IPv4-mapped.
|
|
144
|
+
if (bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff) {
|
|
145
|
+
return classifyIpv4(dotted(12));
|
|
146
|
+
}
|
|
147
|
+
// 64:ff9b::/96 — NAT64 well-known prefix.
|
|
148
|
+
if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b
|
|
149
|
+
&& bytes.slice(4, 12).every((byte) => byte === 0)) {
|
|
150
|
+
return classifyIpv4(dotted(12));
|
|
151
|
+
}
|
|
152
|
+
// 2002::/16 — 6to4, embeds the IPv4 relay address.
|
|
153
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x02) {
|
|
154
|
+
const embedded = classifyIpv4(dotted(2));
|
|
155
|
+
return embedded === 'public' ? 'reserved' : embedded;
|
|
156
|
+
}
|
|
157
|
+
// 64:ff9b:1::/48 — RFC 8215 local-use NAT64, any embedding.
|
|
158
|
+
if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b
|
|
159
|
+
&& bytes[4] === 0x00 && bytes[5] === 0x01) {
|
|
160
|
+
return 'reserved';
|
|
161
|
+
}
|
|
162
|
+
// ::/8 — everything else in the all-zero high byte, which is where the
|
|
163
|
+
// IPv4-compatible (`::127.0.0.1`) and IPv4-translated (`::ffff:0:7f00:1`)
|
|
164
|
+
// forms live. The genuinely useful embeddings are unwrapped above; the
|
|
165
|
+
// rest is refused rather than left to fall through as public.
|
|
166
|
+
if (bytes[0] === 0x00)
|
|
167
|
+
return 'reserved';
|
|
168
|
+
// 100::/64 — discard-only.
|
|
169
|
+
if (bytes[0] === 0x01 && bytes[1] === 0x00 && bytes.slice(2, 8).every((byte) => byte === 0)) {
|
|
170
|
+
return 'reserved';
|
|
171
|
+
}
|
|
172
|
+
if ((bytes[0] & 0xfe) === 0xfc)
|
|
173
|
+
return 'unique-local'; // fc00::/7
|
|
174
|
+
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80)
|
|
175
|
+
return 'link-local'; // fe80::/10
|
|
176
|
+
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0)
|
|
177
|
+
return 'private'; // fec0::/10 site-local
|
|
178
|
+
if (bytes[0] === 0xff)
|
|
179
|
+
return 'multicast'; // ff00::/8
|
|
180
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) {
|
|
181
|
+
return 'documentation'; // 2001:db8::/32
|
|
182
|
+
}
|
|
183
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00) {
|
|
184
|
+
return 'reserved'; // 2001::/32 Teredo
|
|
185
|
+
}
|
|
186
|
+
// Deny-by-default: only global unicast (2000::/3) can be public, and only
|
|
187
|
+
// after the special-purpose prefixes carved out of it above.
|
|
188
|
+
if ((bytes[0] & 0xe0) !== 0x20)
|
|
189
|
+
return 'reserved';
|
|
190
|
+
return 'public';
|
|
191
|
+
}
|
|
192
|
+
/** Classify any IP literal. Anything we cannot parse is refused. */
|
|
193
|
+
export function classifyIpAddress(ip) {
|
|
194
|
+
if (isIPv4(ip))
|
|
195
|
+
return classifyIpv4(ip);
|
|
196
|
+
if (isIPv6(ip))
|
|
197
|
+
return classifyIpv6(ip);
|
|
198
|
+
return 'unparsable';
|
|
199
|
+
}
|
|
200
|
+
const defaultResolver = async (hostname) => {
|
|
201
|
+
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
202
|
+
return records.map((record) => record.address);
|
|
203
|
+
};
|
|
204
|
+
/** `[::1]` → `::1`; other hostnames are returned unchanged. */
|
|
205
|
+
function unbracket(hostname) {
|
|
206
|
+
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Validate one destination and return the address it is allowed to reach.
|
|
210
|
+
*
|
|
211
|
+
* Throws `ProhibitedDestinationError` unless the URL is a credential-free
|
|
212
|
+
* public `http:`/`https:` destination. On success the caller MUST connect to
|
|
213
|
+
* the returned address rather than re-resolving the hostname — re-resolving
|
|
214
|
+
* reopens the rebinding window this function exists to close.
|
|
215
|
+
*
|
|
216
|
+
* Returns `null` only when `allowPrivateDestinations` is set, where there is
|
|
217
|
+
* no policy to pin to.
|
|
218
|
+
*/
|
|
219
|
+
export async function resolvePublicHttpDestination(url, options = {}) {
|
|
220
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
221
|
+
throw new ProhibitedDestinationError(`artifact origin scheme ${url.protocol} is not http(s): ${url.href}`, 'scheme');
|
|
222
|
+
}
|
|
223
|
+
// Credentials are refused regardless of the private-destination hatch:
|
|
224
|
+
// leaking them to a peer is orthogonal to whether the peer is public.
|
|
225
|
+
if (url.username !== '' || url.password !== '') {
|
|
226
|
+
throw new ProhibitedDestinationError(`artifact origin must not carry credentials: ${url.host}`, 'credentials');
|
|
227
|
+
}
|
|
228
|
+
if (options.allowPrivateDestinations === true)
|
|
229
|
+
return null;
|
|
230
|
+
const host = unbracket(url.hostname);
|
|
231
|
+
if (host === '') {
|
|
232
|
+
throw new ProhibitedDestinationError('artifact origin has no host', 'malformed-url');
|
|
233
|
+
}
|
|
234
|
+
if (isIPv4(host) || isIPv6(host)) {
|
|
235
|
+
const verdict = classifyIpAddress(host);
|
|
236
|
+
if (verdict !== 'public') {
|
|
237
|
+
throw new ProhibitedDestinationError(`artifact origin address ${host} is ${verdict}, not a public destination`, verdict);
|
|
238
|
+
}
|
|
239
|
+
return { addresses: [{ address: host, family: isIPv4(host) ? 4 : 6 }] };
|
|
240
|
+
}
|
|
241
|
+
let addresses;
|
|
242
|
+
try {
|
|
243
|
+
addresses = await (options.resolveHostname ?? defaultResolver)(host);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
throw new ProhibitedDestinationError(`artifact origin ${host} did not resolve: ${err instanceof Error ? err.message : String(err)}`, 'unparsable');
|
|
247
|
+
}
|
|
248
|
+
if (addresses.length === 0) {
|
|
249
|
+
throw new ProhibitedDestinationError(`artifact origin ${host} resolved to no addresses`, 'unparsable');
|
|
250
|
+
}
|
|
251
|
+
// Every answer must be public — a name that mixes a public address with a
|
|
252
|
+
// private one is refused outright rather than cherry-picked.
|
|
253
|
+
for (const address of addresses) {
|
|
254
|
+
const verdict = classifyIpAddress(address);
|
|
255
|
+
if (verdict !== 'public') {
|
|
256
|
+
throw new ProhibitedDestinationError(`artifact origin ${host} resolves to ${address} (${verdict}), not a public destination`, verdict);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
addresses: addresses.map((address) => ({
|
|
261
|
+
address,
|
|
262
|
+
family: isIPv4(address) ? 4 : 6,
|
|
263
|
+
})),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch`-shaped transport that connects to a caller-chosen address (#1901).
|
|
3
|
+
*
|
|
4
|
+
* `globalThis.fetch` resolves the hostname itself, at connect time, with no
|
|
5
|
+
* hook to constrain the result. That is fatal for a destination policy: an
|
|
6
|
+
* attacker who controls the name's DNS can answer a public address to the
|
|
7
|
+
* policy's lookup and a private one to the socket's. Validating the name and
|
|
8
|
+
* then handing the name to `fetch` checks one thing and connects to another.
|
|
9
|
+
*
|
|
10
|
+
* So the production path uses `node:http`/`node:https` with the `lookup`
|
|
11
|
+
* option pinned to the address `origin-guard` already approved. The URL keeps
|
|
12
|
+
* its hostname, so TLS still does SNI and certificate validation against the
|
|
13
|
+
* real name; only address selection is taken away from the resolver.
|
|
14
|
+
*
|
|
15
|
+
* The result is adapted back to a web `Response` so callers — and the tests
|
|
16
|
+
* that inject a plain `fetch` fake — see one shape either way. The body is
|
|
17
|
+
* exposed as a stream, never buffered here, so the caller's byte cap still
|
|
18
|
+
* governs.
|
|
19
|
+
*
|
|
20
|
+
* One deliberate difference from `fetch`: there is no transparent content
|
|
21
|
+
* decoding. We send no `Accept-Encoding`, so a compliant origin answers with
|
|
22
|
+
* identity bytes — and the bytes the byte cap counts are then exactly the
|
|
23
|
+
* bytes the caller hashes. An origin that compresses anyway delivers bytes
|
|
24
|
+
* that fail the SHA-256 check, which is the safe direction: nothing
|
|
25
|
+
* unverified is ever admitted.
|
|
26
|
+
*/
|
|
27
|
+
export interface PinnedAddress {
|
|
28
|
+
readonly address: string;
|
|
29
|
+
readonly family: 4 | 6;
|
|
30
|
+
}
|
|
31
|
+
export interface PinnedFetchInit {
|
|
32
|
+
/**
|
|
33
|
+
* Addresses the socket may connect to, in preference order. Every one has
|
|
34
|
+
* already passed the destination policy, so Node is free to fail over
|
|
35
|
+
* between them. Omit to use ordinary DNS.
|
|
36
|
+
*/
|
|
37
|
+
readonly pinnedAddresses?: readonly PinnedAddress[];
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
export type PinnedFetch = (url: URL, init?: PinnedFetchInit) => Promise<Response>;
|
|
41
|
+
/** Perform one GET, pinned to `init.pinnedAddress` when supplied. */
|
|
42
|
+
export declare const pinnedFetch: PinnedFetch;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch`-shaped transport that connects to a caller-chosen address (#1901).
|
|
3
|
+
*
|
|
4
|
+
* `globalThis.fetch` resolves the hostname itself, at connect time, with no
|
|
5
|
+
* hook to constrain the result. That is fatal for a destination policy: an
|
|
6
|
+
* attacker who controls the name's DNS can answer a public address to the
|
|
7
|
+
* policy's lookup and a private one to the socket's. Validating the name and
|
|
8
|
+
* then handing the name to `fetch` checks one thing and connects to another.
|
|
9
|
+
*
|
|
10
|
+
* So the production path uses `node:http`/`node:https` with the `lookup`
|
|
11
|
+
* option pinned to the address `origin-guard` already approved. The URL keeps
|
|
12
|
+
* its hostname, so TLS still does SNI and certificate validation against the
|
|
13
|
+
* real name; only address selection is taken away from the resolver.
|
|
14
|
+
*
|
|
15
|
+
* The result is adapted back to a web `Response` so callers — and the tests
|
|
16
|
+
* that inject a plain `fetch` fake — see one shape either way. The body is
|
|
17
|
+
* exposed as a stream, never buffered here, so the caller's byte cap still
|
|
18
|
+
* governs.
|
|
19
|
+
*
|
|
20
|
+
* One deliberate difference from `fetch`: there is no transparent content
|
|
21
|
+
* decoding. We send no `Accept-Encoding`, so a compliant origin answers with
|
|
22
|
+
* identity bytes — and the bytes the byte cap counts are then exactly the
|
|
23
|
+
* bytes the caller hashes. An origin that compresses anyway delivers bytes
|
|
24
|
+
* that fail the SHA-256 check, which is the safe direction: nothing
|
|
25
|
+
* unverified is ever admitted.
|
|
26
|
+
*/
|
|
27
|
+
import { request as httpRequest } from 'node:http';
|
|
28
|
+
import { request as httpsRequest } from 'node:https';
|
|
29
|
+
import { Readable } from 'node:stream';
|
|
30
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
31
|
+
/** Statuses the `Response` constructor refuses to pair with a body. */
|
|
32
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
33
|
+
/** Perform one GET, pinned to `init.pinnedAddress` when supplied. */
|
|
34
|
+
export const pinnedFetch = (url, init = {}) => new Promise((resolve, reject) => {
|
|
35
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
36
|
+
reject(new Error(`pinnedFetch supports http(s) only, got ${url.protocol}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (init.signal?.aborted) {
|
|
40
|
+
reject(new Error('aborted'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// `undefined` means "no pin requested"; an empty list means "pin requested,
|
|
44
|
+
// nothing permitted". Letting the latter fall through to ordinary DNS would
|
|
45
|
+
// turn an empty allowlist into no restriction at all — the wrong default
|
|
46
|
+
// for a security primitive, and invisible at the call site.
|
|
47
|
+
if (init.pinnedAddresses !== undefined && init.pinnedAddresses.length === 0) {
|
|
48
|
+
reject(new Error('pinnedAddresses must not be empty; omit it to use ordinary DNS'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of init.pinnedAddresses ?? []) {
|
|
52
|
+
const matches = entry.family === 4 ? isIPv4(entry.address) : isIPv6(entry.address);
|
|
53
|
+
if (!matches) {
|
|
54
|
+
reject(new Error(`pinned address ${entry.address} is not a numeric IPv${entry.family} address`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
59
|
+
const pinned = init.pinnedAddresses;
|
|
60
|
+
const outgoing = send(url, {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
// A pooled keep-alive socket is keyed on host:port and ignores `lookup`,
|
|
63
|
+
// so a reused connection would skip the pin entirely. Opting out of the
|
|
64
|
+
// shared agent keeps "we connect to the address we validated" literally
|
|
65
|
+
// true, at the cost of a handshake per artifact fetch.
|
|
66
|
+
...(pinned === undefined ? {} : { agent: false }),
|
|
67
|
+
// Redirects are never followed here; the caller revalidates each hop.
|
|
68
|
+
...(pinned === undefined ? {} : {
|
|
69
|
+
// Node calls `lookup` with `{ all: true }` from some connect paths
|
|
70
|
+
// and expects the array shape back there; answering the wrong shape
|
|
71
|
+
// throws "Invalid IP address: undefined".
|
|
72
|
+
lookup: (_hostname, options, callback) => {
|
|
73
|
+
if (options?.all === true)
|
|
74
|
+
callback(null, pinned.map((entry) => ({ ...entry })));
|
|
75
|
+
else
|
|
76
|
+
callback(null, pinned[0].address, pinned[0].family);
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
}, (incoming) => {
|
|
80
|
+
// Everything here runs inside http.request's response callback, where a
|
|
81
|
+
// throw is an UNCAUGHT EXCEPTION — no promise rejection, nothing the
|
|
82
|
+
// caller's try/catch can see, process dead. The response is
|
|
83
|
+
// attacker-shaped, so the whole callback is guarded and every failure
|
|
84
|
+
// is converted into a rejection this function's caller can handle.
|
|
85
|
+
try {
|
|
86
|
+
const headers = new Headers();
|
|
87
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
continue;
|
|
90
|
+
headers.set(name, Array.isArray(value) ? value.join(', ') : value);
|
|
91
|
+
}
|
|
92
|
+
// Node's parser accepts any status up to 999, but `Response` throws
|
|
93
|
+
// outside 200-599. Check it explicitly rather than leaning on the
|
|
94
|
+
// catch below, so a hostile `HTTP/1.1 999 Nope` reads as a bad
|
|
95
|
+
// response instead of an internal error.
|
|
96
|
+
const status = incoming.statusCode ?? 502;
|
|
97
|
+
if (status < 200 || status > 599) {
|
|
98
|
+
incoming.resume();
|
|
99
|
+
reject(new Error(`origin returned an out-of-range HTTP status ${status}`));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const bodyless = NULL_BODY_STATUSES.has(status);
|
|
103
|
+
if (bodyless)
|
|
104
|
+
incoming.resume();
|
|
105
|
+
resolve(new Response(bodyless ? null : Readable.toWeb(incoming), { status, headers }));
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
incoming.resume();
|
|
109
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
outgoing.on('error', reject);
|
|
113
|
+
if (init.signal) {
|
|
114
|
+
const abort = () => { outgoing.destroy(new Error('aborted')); };
|
|
115
|
+
if (init.signal.aborted)
|
|
116
|
+
abort();
|
|
117
|
+
else
|
|
118
|
+
init.signal.addEventListener('abort', abort, { once: true });
|
|
119
|
+
}
|
|
120
|
+
outgoing.end();
|
|
121
|
+
});
|
package/dist/envelope.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* doc are parsed against this schema in `test/envelope.test.ts`).
|
|
16
16
|
*
|
|
17
17
|
* Step shape mirrors the capture-span shape (`SpanRow`,
|
|
18
|
-
* `
|
|
18
|
+
* `operator/src/store/captures.ts`) minus session/trace IDs, which are hoisted
|
|
19
19
|
* to `session`.
|
|
20
20
|
*
|
|
21
21
|
* Plan: docs/superpowers/plans/2026-07-02-jinn-harness-network-v0-plan.md
|
|
@@ -56,7 +56,7 @@ export declare const OutcomeStatusSchema: z.ZodEnum<{
|
|
|
56
56
|
export type OutcomeStatus = z.infer<typeof OutcomeStatusSchema>;
|
|
57
57
|
/**
|
|
58
58
|
* One compressed trace step. Mirrors `SpanRow`
|
|
59
|
-
* (
|
|
59
|
+
* (operator/src/store/captures.ts): scrubbed `attributes` plus the
|
|
60
60
|
* `redactedKeys` receipt of what the scrub pipeline removed.
|
|
61
61
|
*/
|
|
62
62
|
export declare const TraceStepSchema: z.ZodObject<{
|
package/dist/envelope.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* doc are parsed against this schema in `test/envelope.test.ts`).
|
|
16
16
|
*
|
|
17
17
|
* Step shape mirrors the capture-span shape (`SpanRow`,
|
|
18
|
-
* `
|
|
18
|
+
* `operator/src/store/captures.ts`) minus session/trace IDs, which are hoisted
|
|
19
19
|
* to `session`.
|
|
20
20
|
*
|
|
21
21
|
* Plan: docs/superpowers/plans/2026-07-02-jinn-harness-network-v0-plan.md
|
|
@@ -53,7 +53,7 @@ const UnixNanoSchema = z.string().regex(/^\d+$/, 'unix-nanosecond digit string')
|
|
|
53
53
|
const TrimmedString = (max) => z.string().min(1).max(max).regex(/^\S(.*\S)?$/s, 'no surrounding whitespace');
|
|
54
54
|
/**
|
|
55
55
|
* One compressed trace step. Mirrors `SpanRow`
|
|
56
|
-
* (
|
|
56
|
+
* (operator/src/store/captures.ts): scrubbed `attributes` plus the
|
|
57
57
|
* `redactedKeys` receipt of what the scrub pipeline removed.
|
|
58
58
|
*/
|
|
59
59
|
export const TraceStepSchema = z.strictObject({
|
package/dist/skill-artifact.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { z } from 'zod/v3';
|
|
|
11
11
|
* so they publish as a skill-only wrapper (`publishSkill()`,
|
|
12
12
|
* packages/layer/src/publish-skill.ts, DR-2026-07-06).
|
|
13
13
|
* Access/pricing come from the enclosing Artifact
|
|
14
|
-
* entry (
|
|
14
|
+
* entry (operator/src/types/envelope.ts ArtifactSchema: sha256,
|
|
15
15
|
* access.endpoint, access.priceUsdc, metadata.tags).
|
|
16
16
|
*
|
|
17
17
|
* Frozen-caps review (AC3, additive): this schema is a NEW artifact payload
|
|
@@ -22,7 +22,7 @@ import { z } from 'zod/v3';
|
|
|
22
22
|
* any artifactType string. Companion-file content lives here, free of the
|
|
23
23
|
* trace envelope's 16 KiB step-attribute cap, under its own 1 MiB total cap.
|
|
24
24
|
*
|
|
25
|
-
* Precedent:
|
|
25
|
+
* Precedent: operator/src/trajectory/harness-bundle-schema.ts.
|
|
26
26
|
*/
|
|
27
27
|
export declare const SKILL_ARTIFACT_TYPE: "jinn.skill.v1";
|
|
28
28
|
/** Max companion files per skill. */
|
package/dist/skill-artifact.js
CHANGED
|
@@ -11,7 +11,7 @@ import { z } from 'zod/v3';
|
|
|
11
11
|
* so they publish as a skill-only wrapper (`publishSkill()`,
|
|
12
12
|
* packages/layer/src/publish-skill.ts, DR-2026-07-06).
|
|
13
13
|
* Access/pricing come from the enclosing Artifact
|
|
14
|
-
* entry (
|
|
14
|
+
* entry (operator/src/types/envelope.ts ArtifactSchema: sha256,
|
|
15
15
|
* access.endpoint, access.priceUsdc, metadata.tags).
|
|
16
16
|
*
|
|
17
17
|
* Frozen-caps review (AC3, additive): this schema is a NEW artifact payload
|
|
@@ -22,7 +22,7 @@ import { z } from 'zod/v3';
|
|
|
22
22
|
* any artifactType string. Companion-file content lives here, free of the
|
|
23
23
|
* trace envelope's 16 KiB step-attribute cap, under its own 1 MiB total cap.
|
|
24
24
|
*
|
|
25
|
-
* Precedent:
|
|
25
|
+
* Precedent: operator/src/trajectory/harness-bundle-schema.ts.
|
|
26
26
|
*/
|
|
27
27
|
export const SKILL_ARTIFACT_TYPE = 'jinn.skill.v1';
|
|
28
28
|
/** Max companion files per skill. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jinn-network/core",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Domain core for Jinn — evidence/contribution stores, scrub, trajectory parsing, and corpus reads. Independent of
|
|
3
|
+
"version": "0.1.2-canary.15837edd",
|
|
4
|
+
"description": "Domain core for Jinn — evidence/contribution stores, scrub, trajectory parsing, and corpus reads. Independent of operator/src.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "yarn@4.13.0",
|
|
7
7
|
"license": "MIT",
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@huggingface/transformers": "^4.2.0",
|
|
46
|
-
"@jinn-network/plugin": "0.1.
|
|
46
|
+
"@jinn-network/plugin": "0.1.2-canary.15837edd",
|
|
47
47
|
"@lmoe/gliner-onnx": "0.1.0",
|
|
48
48
|
"@noble/hashes": "^2.2.0",
|
|
49
49
|
"@secretlint/core": "^13.0.2",
|
|
50
50
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
|
|
51
51
|
"better-sqlite3": "^12.10.0",
|
|
52
52
|
"canonicalize": "^3.0.0",
|
|
53
|
-
"zod": "
|
|
53
|
+
"zod": "4.4.3"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"vitest": "^4.1.8"
|