haechi 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +20 -6
- package/README.md +20 -6
- package/docs/current/api-stability.ko.md +95 -45
- package/docs/current/api-stability.md +95 -45
- package/docs/current/configuration.ko.md +106 -2
- package/docs/current/configuration.md +106 -2
- package/docs/current/release-0.9-implementation-scope.ko.md +231 -0
- package/docs/current/release-0.9-implementation-scope.md +231 -0
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/release-process.ko.md +7 -1
- package/docs/current/release-process.md +7 -1
- package/docs/current/risk-register-release-gate.ko.md +30 -7
- package/docs/current/risk-register-release-gate.md +28 -5
- package/docs/current/threat-model.ko.md +29 -1
- package/docs/current/threat-model.md +29 -1
- package/haechi.config.example.json +2 -1
- package/package.json +4 -3
- package/packages/audit/index.mjs +24 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/runtime.mjs +189 -6
- package/packages/core/index.mjs +23 -4
- package/packages/filter/index.mjs +58 -3
- package/packages/plugin/index.mjs +83 -17
- package/packages/plugin/sandbox.mjs +608 -0
- package/packages/plugin/signing.mjs +393 -0
- package/packages/proxy/index.mjs +3 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Ed25519 signed-plugin primitive (the 1.0 cryptographic trust gate).
|
|
2
|
+
//
|
|
3
|
+
// This is ASYMMETRIC signing — the plugin AUTHOR holds the Ed25519 private key
|
|
4
|
+
// and signs offline; the OPERATOR allowlists the Ed25519 PUBLIC key as a trust
|
|
5
|
+
// anchor and verifies. It deliberately does NOT reuse packages/policy-bundle:
|
|
6
|
+
// that is symmetric HMAC keyed off the local AES key file, where the verifier
|
|
7
|
+
// holds the same secret that signs, so it cannot express third-party authorship.
|
|
8
|
+
//
|
|
9
|
+
// The signature binds the sha256 of the EXACT entry bytes plus kind,
|
|
10
|
+
// capabilities, the compatible core range, and a validity window — so signing a
|
|
11
|
+
// path, or omitting entrySha256/kind/capabilities, is a swap / capability-
|
|
12
|
+
// downgrade attack and is rejected by verifySignedPlugin.
|
|
13
|
+
//
|
|
14
|
+
// Zero new runtime dependency: node:crypto (Ed25519 is a builtin) + the core
|
|
15
|
+
// canonicalize() for the signed bytes so sign and verify agree byte-for-byte.
|
|
16
|
+
|
|
17
|
+
import { createHash, createPublicKey, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { canonicalize } from "../crypto/index.mjs";
|
|
19
|
+
|
|
20
|
+
// Minimal node:-only semver satisfies for the ">=A.B.C <D.E.F" range shape.
|
|
21
|
+
// Inlined rather than imported from scripts/ — scripts/check-satellite-peer-ranges.mjs
|
|
22
|
+
// is NOT in the published `files` allowlist, so a cross-import would
|
|
23
|
+
// MODULE_NOT_FOUND in the haechi tarball at runtime.
|
|
24
|
+
function parseSemver(v) {
|
|
25
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v).trim());
|
|
26
|
+
if (!m) throw new Error(`unsupported version: ${JSON.stringify(v)}`);
|
|
27
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
28
|
+
}
|
|
29
|
+
function cmpSemver(a, b) {
|
|
30
|
+
for (let i = 0; i < 3; i += 1) {
|
|
31
|
+
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
function semverSatisfies(version, range) {
|
|
36
|
+
const m = /^>=(\d+\.\d+\.\d+)\s+<(\d+\.\d+\.\d+)$/.exec(String(range).trim());
|
|
37
|
+
if (!m) {
|
|
38
|
+
throw new Error(`unsupported range shape (expected ">=A.B.C <D.E.F"): ${JSON.stringify(range)}`);
|
|
39
|
+
}
|
|
40
|
+
return cmpSemver(parseSemver(version), parseSemver(m[1])) >= 0
|
|
41
|
+
&& cmpSemver(parseSemver(version), parseSemver(m[2])) < 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The verifySignedPlugin refusal reasons — security-critical and ordered.
|
|
45
|
+
// PluginLoadError.reason is guaranteed to be a member of this set.
|
|
46
|
+
export const PLUGIN_LOAD_REASONS = Object.freeze([
|
|
47
|
+
"manifest-invalid",
|
|
48
|
+
"alg-not-ed25519",
|
|
49
|
+
"unknown-signer",
|
|
50
|
+
"revoked",
|
|
51
|
+
"tampered-entry",
|
|
52
|
+
"invalid-signature",
|
|
53
|
+
"expired-window",
|
|
54
|
+
"below-version-floor",
|
|
55
|
+
"pin-mismatch",
|
|
56
|
+
"capability-not-allowlisted"
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const PLUGIN_LOAD_REASON_SET = new Set(PLUGIN_LOAD_REASONS);
|
|
60
|
+
|
|
61
|
+
// A typed, fail-closed error. Every refusal path throws this with a .reason in
|
|
62
|
+
// PLUGIN_LOAD_REASONS so the loader/audit can branch on a stable enum, never on
|
|
63
|
+
// a free-text message.
|
|
64
|
+
export class PluginLoadError extends Error {
|
|
65
|
+
constructor(reason, message) {
|
|
66
|
+
if (!PLUGIN_LOAD_REASON_SET.has(reason)) {
|
|
67
|
+
// A programming error inside the verifier — never surface an off-contract
|
|
68
|
+
// reason to a caller relying on the enum.
|
|
69
|
+
throw new Error(`PluginLoadError got an off-contract reason: ${reason}`);
|
|
70
|
+
}
|
|
71
|
+
super(message ?? reason);
|
|
72
|
+
this.name = "PluginLoadError";
|
|
73
|
+
this.reason = reason;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sha256Hex(bytes) {
|
|
78
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toEntryBuffer(entryBytes) {
|
|
82
|
+
if (Buffer.isBuffer(entryBytes)) {
|
|
83
|
+
return entryBytes;
|
|
84
|
+
}
|
|
85
|
+
if (entryBytes instanceof Uint8Array) {
|
|
86
|
+
return Buffer.from(entryBytes);
|
|
87
|
+
}
|
|
88
|
+
if (typeof entryBytes === "string") {
|
|
89
|
+
return Buffer.from(entryBytes, "utf8");
|
|
90
|
+
}
|
|
91
|
+
throw new Error("entryBytes must be a Buffer, Uint8Array, or string");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compares two hex-encoded sha256 digests without leaking position-of-first-
|
|
95
|
+
// difference timing. Both are attacker-influenced/operator-supplied digests, so
|
|
96
|
+
// the constant-time compare is defense-in-depth (and required by the spec).
|
|
97
|
+
function constantTimeHexEqual(a, b) {
|
|
98
|
+
if (typeof a !== "string" || typeof b !== "string") {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const bufA = Buffer.from(a, "utf8");
|
|
102
|
+
const bufB = Buffer.from(b, "utf8");
|
|
103
|
+
if (bufA.length !== bufB.length) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return timingSafeEqual(bufA, bufB);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// TEST/AUTHORING HELPER. Real authors sign offline with their own tooling; this
|
|
110
|
+
// exists so tests (and a future signing CLI) can produce a valid envelope.
|
|
111
|
+
//
|
|
112
|
+
// Returns { payload, signerKeyId, alg: "ed25519", signature } where
|
|
113
|
+
// payload = { pluginId, kind, version, capabilities, coreVersionRange,
|
|
114
|
+
// entrySha256: sha256hex(entryBytes), notBefore, notAfter }
|
|
115
|
+
// signature = base64( ed25519.sign(canonicalize(payload)) )
|
|
116
|
+
export function signPluginManifest(
|
|
117
|
+
{ pluginId, kind, version, capabilities, coreVersionRange, entryBytes, notBefore, notAfter },
|
|
118
|
+
privateKey,
|
|
119
|
+
signerKeyId
|
|
120
|
+
) {
|
|
121
|
+
if (!pluginId || typeof pluginId !== "string") {
|
|
122
|
+
throw new Error("signPluginManifest requires a non-empty pluginId");
|
|
123
|
+
}
|
|
124
|
+
if (!kind || typeof kind !== "string") {
|
|
125
|
+
throw new Error("signPluginManifest requires a non-empty kind");
|
|
126
|
+
}
|
|
127
|
+
if (!version || typeof version !== "string") {
|
|
128
|
+
throw new Error("signPluginManifest requires a non-empty version");
|
|
129
|
+
}
|
|
130
|
+
if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
|
|
131
|
+
throw new Error("signPluginManifest requires a capabilities object");
|
|
132
|
+
}
|
|
133
|
+
if (!coreVersionRange || typeof coreVersionRange !== "string") {
|
|
134
|
+
throw new Error("signPluginManifest requires a coreVersionRange string");
|
|
135
|
+
}
|
|
136
|
+
if (!signerKeyId || typeof signerKeyId !== "string") {
|
|
137
|
+
throw new Error("signPluginManifest requires a signerKeyId");
|
|
138
|
+
}
|
|
139
|
+
if (entryBytes === undefined || entryBytes === null) {
|
|
140
|
+
throw new Error("signPluginManifest requires entryBytes (the exact plugin source bytes)");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entrySha256 = sha256Hex(toEntryBuffer(entryBytes));
|
|
144
|
+
const payload = {
|
|
145
|
+
pluginId,
|
|
146
|
+
kind,
|
|
147
|
+
version,
|
|
148
|
+
capabilities,
|
|
149
|
+
coreVersionRange,
|
|
150
|
+
entrySha256,
|
|
151
|
+
notBefore: notBefore ?? null,
|
|
152
|
+
notAfter: notAfter ?? null
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Ed25519: the algorithm arg to crypto.sign is NULL.
|
|
156
|
+
const signature = edSign(null, Buffer.from(canonicalize(payload), "utf8"), privateKey);
|
|
157
|
+
return {
|
|
158
|
+
payload,
|
|
159
|
+
signerKeyId,
|
|
160
|
+
alg: "ed25519",
|
|
161
|
+
signature: signature.toString("base64")
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveAnchorPublicKey(anchor) {
|
|
166
|
+
// A trust anchor may be supplied as a KeyObject, a PEM/SPKI string, or a
|
|
167
|
+
// { publicKey } wrapper. Resolve to a KeyObject; reject anything else.
|
|
168
|
+
if (anchor && typeof anchor === "object" && anchor.publicKey !== undefined && anchor.type === undefined) {
|
|
169
|
+
return resolveAnchorPublicKey(anchor.publicKey);
|
|
170
|
+
}
|
|
171
|
+
if (anchor && typeof anchor === "object" && anchor.asymmetricKeyType !== undefined) {
|
|
172
|
+
// A KeyObject already.
|
|
173
|
+
return anchor;
|
|
174
|
+
}
|
|
175
|
+
if (typeof anchor === "string") {
|
|
176
|
+
return createPublicKey(anchor);
|
|
177
|
+
}
|
|
178
|
+
// Last resort: let createPublicKey try (e.g. a JWK object / DER buffer).
|
|
179
|
+
return createPublicKey(anchor);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Verify a signed plugin envelope against operator trust state. Returns the
|
|
183
|
+
// validated payload, or throws a PluginLoadError whose .reason is in
|
|
184
|
+
// PLUGIN_LOAD_REASONS. The CHECK ORDER is security-critical (see the design
|
|
185
|
+
// §2.2 / §7.3) and must not be reordered.
|
|
186
|
+
export function verifySignedPlugin({
|
|
187
|
+
signed,
|
|
188
|
+
entryBytes,
|
|
189
|
+
trustAnchors = {},
|
|
190
|
+
revoked = {},
|
|
191
|
+
pin = null,
|
|
192
|
+
versionFloor = {},
|
|
193
|
+
allowCapabilities = [],
|
|
194
|
+
coreVersion = null,
|
|
195
|
+
now = Date.now()
|
|
196
|
+
} = {}) {
|
|
197
|
+
// (0) Structural validity of the envelope itself.
|
|
198
|
+
if (!signed || typeof signed !== "object") {
|
|
199
|
+
throw new PluginLoadError("manifest-invalid", "signed envelope must be an object");
|
|
200
|
+
}
|
|
201
|
+
const { payload, signerKeyId, alg, signature } = signed;
|
|
202
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
203
|
+
throw new PluginLoadError("manifest-invalid", "signed.payload must be an object");
|
|
204
|
+
}
|
|
205
|
+
if (typeof signerKeyId !== "string" || signerKeyId.length === 0) {
|
|
206
|
+
throw new PluginLoadError("manifest-invalid", "signed.signerKeyId must be a non-empty string");
|
|
207
|
+
}
|
|
208
|
+
if (typeof signature !== "string" || signature.length === 0) {
|
|
209
|
+
throw new PluginLoadError("manifest-invalid", "signed.signature must be a non-empty base64 string");
|
|
210
|
+
}
|
|
211
|
+
for (const field of ["pluginId", "kind", "version", "coreVersionRange", "entrySha256"]) {
|
|
212
|
+
if (typeof payload[field] !== "string" || payload[field].length === 0) {
|
|
213
|
+
throw new PluginLoadError("manifest-invalid", `signed.payload.${field} must be a non-empty string`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!payload.capabilities || typeof payload.capabilities !== "object" || Array.isArray(payload.capabilities)) {
|
|
217
|
+
throw new PluginLoadError("manifest-invalid", "signed.payload.capabilities must be an object");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// (a) Algorithm is pinned to ed25519 — no alg agility, no HS/RS confusion.
|
|
221
|
+
if (alg !== "ed25519") {
|
|
222
|
+
throw new PluginLoadError("alg-not-ed25519", `unsupported signature alg: ${String(alg)}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// (b) Resolve the verification key ONLY from the operator's trustAnchors
|
|
226
|
+
// allowlist, keyed by signed.signerKeyId. If the kid is not an allowlisted
|
|
227
|
+
// anchor, refuse BEFORE any verify — never select a key by the object's own
|
|
228
|
+
// claim against a broader keyring.
|
|
229
|
+
const hasAnchor = Object.prototype.hasOwnProperty.call(trustAnchors, signerKeyId);
|
|
230
|
+
if (!hasAnchor) {
|
|
231
|
+
throw new PluginLoadError("unknown-signer", `signerKeyId not in trust anchors: ${signerKeyId}`);
|
|
232
|
+
}
|
|
233
|
+
let resolvedPublicKey;
|
|
234
|
+
try {
|
|
235
|
+
resolvedPublicKey = resolveAnchorPublicKey(trustAnchors[signerKeyId]);
|
|
236
|
+
if (!resolvedPublicKey || resolvedPublicKey.asymmetricKeyType !== "ed25519") {
|
|
237
|
+
throw new Error("trust anchor is not an ed25519 public key");
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// A malformed anchor is an operator config error, but the safe outcome is
|
|
241
|
+
// still to refuse the load as an unusable signer.
|
|
242
|
+
throw new PluginLoadError("unknown-signer", `trust anchor unusable for ${signerKeyId}: ${error.message}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// (c) Revoked signer denylist (fail-closed before the expensive verify).
|
|
246
|
+
const revokedSignerKeyIds = Array.isArray(revoked.signerKeyIds) ? revoked.signerKeyIds : [];
|
|
247
|
+
if (revokedSignerKeyIds.includes(signerKeyId)) {
|
|
248
|
+
throw new PluginLoadError("revoked", `signerKeyId is revoked: ${signerKeyId}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// (d) Bind to the EXACT entry bytes: recompute the hash and compare in
|
|
252
|
+
// constant time. A mutated entry (path unchanged) trips here BEFORE the
|
|
253
|
+
// signature check, so a swap is "tampered-entry", not "invalid-signature".
|
|
254
|
+
const entrySha256 = sha256Hex(toEntryBuffer(entryBytes));
|
|
255
|
+
if (!constantTimeHexEqual(payload.entrySha256, entrySha256)) {
|
|
256
|
+
throw new PluginLoadError("tampered-entry", "entry bytes do not match the signed entrySha256");
|
|
257
|
+
}
|
|
258
|
+
const revokedEntrySha256 = Array.isArray(revoked.entrySha256) ? revoked.entrySha256 : [];
|
|
259
|
+
if (revokedEntrySha256.includes(entrySha256)) {
|
|
260
|
+
throw new PluginLoadError("revoked", `entrySha256 is revoked: ${entrySha256}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// (e) Ed25519 signature over the canonical payload (algorithm arg NULL).
|
|
264
|
+
let signatureValid = false;
|
|
265
|
+
try {
|
|
266
|
+
signatureValid = edVerify(
|
|
267
|
+
null,
|
|
268
|
+
Buffer.from(canonicalize(payload), "utf8"),
|
|
269
|
+
resolvedPublicKey,
|
|
270
|
+
Buffer.from(signature, "base64")
|
|
271
|
+
);
|
|
272
|
+
} catch {
|
|
273
|
+
signatureValid = false;
|
|
274
|
+
}
|
|
275
|
+
if (!signatureValid) {
|
|
276
|
+
throw new PluginLoadError("invalid-signature", "ed25519 signature verification failed");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// (f) Validity window (notBefore/notAfter). Both are epoch-ms numbers (or
|
|
280
|
+
// null = unbounded on that side).
|
|
281
|
+
const nowMs = typeof now === "number" ? now : Date.parse(now);
|
|
282
|
+
const notBefore = normalizeWindowBound(payload.notBefore);
|
|
283
|
+
const notAfter = normalizeWindowBound(payload.notAfter);
|
|
284
|
+
if (notBefore !== null && nowMs < notBefore) {
|
|
285
|
+
throw new PluginLoadError("expired-window", "current time is before notBefore");
|
|
286
|
+
}
|
|
287
|
+
if (notAfter !== null && nowMs > notAfter) {
|
|
288
|
+
throw new PluginLoadError("expired-window", "current time is after notAfter");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// (g) Per-pluginId version floor — reject rollback to an older signed artifact.
|
|
292
|
+
const floor = versionFloor?.[payload.pluginId];
|
|
293
|
+
if (floor !== undefined && floor !== null && compareVersions(payload.version, floor) < 0) {
|
|
294
|
+
throw new PluginLoadError("below-version-floor", `version ${payload.version} below floor ${floor}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// (h) Pin (anti malicious-update / rollback): version / entrySha256 /
|
|
298
|
+
// manifestSha256 must match the operator pin exactly.
|
|
299
|
+
if (pin && typeof pin === "object") {
|
|
300
|
+
if (pin.version !== undefined && pin.version !== null && pin.version !== payload.version) {
|
|
301
|
+
throw new PluginLoadError("pin-mismatch", "version does not match pin");
|
|
302
|
+
}
|
|
303
|
+
if (pin.entrySha256 !== undefined && pin.entrySha256 !== null
|
|
304
|
+
&& !constantTimeHexEqual(pin.entrySha256, entrySha256)) {
|
|
305
|
+
throw new PluginLoadError("pin-mismatch", "entrySha256 does not match pin");
|
|
306
|
+
}
|
|
307
|
+
if (pin.manifestSha256 !== undefined && pin.manifestSha256 !== null) {
|
|
308
|
+
const manifestSha256 = sha256Hex(Buffer.from(canonicalize(payload), "utf8"));
|
|
309
|
+
if (!constantTimeHexEqual(pin.manifestSha256, manifestSha256)) {
|
|
310
|
+
throw new PluginLoadError("pin-mismatch", "manifestSha256 does not match pin");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// (i) Capability allowlist: every capability value MUST be a strict boolean
|
|
316
|
+
// (non-boolean truthy values like 1/"true"/{} skip the === true gate and are
|
|
317
|
+
// a bypass). Reject at the trust boundary before the allowlist check.
|
|
318
|
+
const allowSet = new Set(Array.isArray(allowCapabilities) ? allowCapabilities : []);
|
|
319
|
+
for (const [capability, requested] of Object.entries(payload.capabilities)) {
|
|
320
|
+
if (typeof requested !== "boolean") {
|
|
321
|
+
throw new PluginLoadError("manifest-invalid", `capability value must be a boolean, got ${typeof requested} for: ${capability}`);
|
|
322
|
+
}
|
|
323
|
+
if (requested === true && !allowSet.has(capability)) {
|
|
324
|
+
throw new PluginLoadError("capability-not-allowlisted", `capability not allowlisted: ${capability}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (payload.kind === "authProvider" && payload.capabilities.readsCredentials !== true) {
|
|
328
|
+
throw new PluginLoadError("capability-not-allowlisted", "authProvider must declare readsCredentials");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// (j) coreVersionRange enforcement: when the caller supplies coreVersion AND
|
|
332
|
+
// the signed payload declares coreVersionRange, the version must satisfy the
|
|
333
|
+
// range. A mismatch means this plugin was not signed for this core — refuse.
|
|
334
|
+
if (coreVersion !== null && coreVersion !== undefined && payload.coreVersionRange) {
|
|
335
|
+
let inRange;
|
|
336
|
+
try {
|
|
337
|
+
inRange = semverSatisfies(String(coreVersion), payload.coreVersionRange);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
throw new PluginLoadError("manifest-invalid", `coreVersionRange is not a valid range: ${err.message}`);
|
|
340
|
+
}
|
|
341
|
+
if (!inRange) {
|
|
342
|
+
throw new PluginLoadError("manifest-invalid", `coreVersion ${coreVersion} does not satisfy signed coreVersionRange ${payload.coreVersionRange}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The validated payload — frozen so a downstream consumer cannot mutate the
|
|
347
|
+
// attested facts.
|
|
348
|
+
return Object.freeze({ ...payload });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizeWindowBound(bound) {
|
|
352
|
+
if (bound === null || bound === undefined) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
if (typeof bound === "number") {
|
|
356
|
+
if (!Number.isFinite(bound)) {
|
|
357
|
+
throw new PluginLoadError("manifest-invalid", `validity window bound is not a finite number: ${bound}`);
|
|
358
|
+
}
|
|
359
|
+
return bound;
|
|
360
|
+
}
|
|
361
|
+
if (typeof bound === "string" && bound.length > 0) {
|
|
362
|
+
const parsed = Date.parse(bound);
|
|
363
|
+
if (Number.isNaN(parsed)) {
|
|
364
|
+
throw new PluginLoadError("manifest-invalid", `validity window bound is not a parseable date: ${JSON.stringify(bound)}`);
|
|
365
|
+
}
|
|
366
|
+
return parsed;
|
|
367
|
+
}
|
|
368
|
+
// Anything else (boolean, object, empty string, etc.) fails closed.
|
|
369
|
+
throw new PluginLoadError("manifest-invalid", `validity window bound has an unacceptable type/value: ${JSON.stringify(bound)}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Minimal numeric-dotted version comparison (e.g. "1.2.0" vs "1.10.0"). Returns
|
|
373
|
+
// -1 / 0 / 1. Non-numeric segments compare lexicographically as a fallback so a
|
|
374
|
+
// malformed version can never silently rank as "newer".
|
|
375
|
+
function compareVersions(a, b) {
|
|
376
|
+
const pa = String(a).split(".");
|
|
377
|
+
const pb = String(b).split(".");
|
|
378
|
+
const len = Math.max(pa.length, pb.length);
|
|
379
|
+
for (let i = 0; i < len; i += 1) {
|
|
380
|
+
const sa = pa[i] ?? "0";
|
|
381
|
+
const sb = pb[i] ?? "0";
|
|
382
|
+
const na = Number(sa);
|
|
383
|
+
const nb = Number(sb);
|
|
384
|
+
if (Number.isInteger(na) && Number.isInteger(nb)) {
|
|
385
|
+
if (na !== nb) {
|
|
386
|
+
return na < nb ? -1 : 1;
|
|
387
|
+
}
|
|
388
|
+
} else if (sa !== sb) {
|
|
389
|
+
return sa < sb ? -1 : 1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
package/packages/proxy/index.mjs
CHANGED
|
@@ -438,6 +438,9 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, a
|
|
|
438
438
|
...authContext,
|
|
439
439
|
operation: `response:${routeContext.operation}`,
|
|
440
440
|
direction: "response",
|
|
441
|
+
// Opt-in: scan bare number leaves on the response (off by default — they are
|
|
442
|
+
// inference-server metadata; see the filter engine's number-leaf skip).
|
|
443
|
+
scanNumbers: runtime.config.responseProtection.scanNumbers,
|
|
441
444
|
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
442
445
|
});
|
|
443
446
|
|