@blamejs/core 0.9.46 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -60,11 +60,20 @@ var agentAudit = require("./agent-audit");
|
|
|
60
60
|
|
|
61
61
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
62
62
|
var cluster = lazyRequire(function () { return require("./cluster"); });
|
|
63
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
63
64
|
|
|
64
65
|
var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
|
|
65
66
|
|
|
66
67
|
var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
|
|
67
68
|
var STREAM_ID_RAND_BYTES = 8; // allow:raw-byte-literal — stream-id random-suffix byte length, not a size cap
|
|
69
|
+
var DEFAULT_PER_CONSUMER_STOP_MS = C.TIME.seconds(5);
|
|
70
|
+
// SUBSTRATE-20 — FNV-1a offset basis salted with the first 32 bits of
|
|
71
|
+
// SHA3-512(vault master). Attackers who don't have read access to the
|
|
72
|
+
// vault keypair can't compute the salt, so they can't engineer
|
|
73
|
+
// tenantIds that all map to one shard. Cached per-process; rotation
|
|
74
|
+
// of vault keys produces a new basis (operator opts to reshard via
|
|
75
|
+
// rebalance — same property as a manual `vault.rotate`).
|
|
76
|
+
var _saltedFnvBasisCache = null;
|
|
68
77
|
|
|
69
78
|
/**
|
|
70
79
|
* @primitive b.agent.orchestrator.create
|
|
@@ -116,6 +125,19 @@ function create(opts) {
|
|
|
116
125
|
// operator-supplied metadata (kind / tenantId / posture / ...);
|
|
117
126
|
// every consuming process holds its own runtime map of name → agent.
|
|
118
127
|
liveAgents: new Map(),
|
|
128
|
+
// SUBSTRATE-8 — drain quiesce wiring. Operator passes
|
|
129
|
+
// { outbox, sagaInFlightCount, pubsubFlush } via create() so the
|
|
130
|
+
// drain phase can quiesce real in-flight work, not just stop
|
|
131
|
+
// consumers. Optional — operators with no outbox / saga / pubsub
|
|
132
|
+
// pass nothing and drain falls back to the consumer-stop path.
|
|
133
|
+
outbox: opts.outbox || null,
|
|
134
|
+
sagaInFlightCount: typeof opts.sagaInFlightCount === "function" ? opts.sagaInFlightCount : null,
|
|
135
|
+
pubsubFlush: typeof opts.pubsubFlush === "function" ? opts.pubsubFlush : null,
|
|
136
|
+
perConsumerStopMs: typeof opts.perConsumerStopMs === "number" ? opts.perConsumerStopMs : DEFAULT_PER_CONSUMER_STOP_MS,
|
|
137
|
+
// SUBSTRATE-9 — onTransition handler invalidates election cache
|
|
138
|
+
// on lease-lost / acquired / released. Operator opts out via
|
|
139
|
+
// { cacheElections: false } to always re-query b.cluster.
|
|
140
|
+
cacheElections: opts.cacheElections !== false,
|
|
119
141
|
};
|
|
120
142
|
|
|
121
143
|
// Wire the drain phase into b.appShutdown if the operator supplied one.
|
|
@@ -125,8 +147,33 @@ function create(opts) {
|
|
|
125
147
|
});
|
|
126
148
|
}
|
|
127
149
|
|
|
150
|
+
// SUBSTRATE-9 — subscribe to cluster lease transitions so cached
|
|
151
|
+
// election state can't go stale after a partition. b.cluster
|
|
152
|
+
// .onTransition fires for every lease-acquired / lease-lost / lease-
|
|
153
|
+
// released event; we invalidate the affected resource's cached
|
|
154
|
+
// election so the next elect() call re-queries truth. When
|
|
155
|
+
// b.cluster isn't initialized (single-process deployments)
|
|
156
|
+
// onTransition still registers a handler — the dispatcher silently
|
|
157
|
+
// never fires until init completes.
|
|
158
|
+
if (clusterImpl && typeof clusterImpl.onTransition === "function") {
|
|
159
|
+
try {
|
|
160
|
+
clusterImpl.onTransition(function (event) {
|
|
161
|
+
// event.kind ∈ { "lease-acquired" | "lease-lost" |
|
|
162
|
+
// "lease-released" | "lease-renewed" }. Every
|
|
163
|
+
// kind invalidates because membership / fencing-token may
|
|
164
|
+
// have changed.
|
|
165
|
+
ctx.elections.clear();
|
|
166
|
+
agentAudit.safeAudit(ctx.audit, "agent.orchestrator.election_cache_invalidated", null, {
|
|
167
|
+
kind: event && event.kind ? event.kind : "unknown",
|
|
168
|
+
fencingToken: event && event.fencingToken ? event.fencingToken : null,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
} catch (_e) { /* drop-silent — onTransition unavailable in some test stubs */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
return {
|
|
129
175
|
register: function (name, agent, regOpts) { return _register(ctx, name, agent, regOpts || {}); },
|
|
176
|
+
hydrate: function (name, agent) { return _hydrate(ctx, name, agent); },
|
|
130
177
|
unregister: function (name, args) { return _unregister(ctx, name, args || {}); },
|
|
131
178
|
lookup: function (name, args) { return _lookup(ctx, name, args || {}); },
|
|
132
179
|
list: function (args) { return _list(ctx, args || {}); },
|
|
@@ -142,6 +189,70 @@ function create(opts) {
|
|
|
142
189
|
};
|
|
143
190
|
}
|
|
144
191
|
|
|
192
|
+
/**
|
|
193
|
+
* @primitive b.agent.orchestrator.hydrate
|
|
194
|
+
* @signature b.agent.orchestrator.hydrate(name, agent)
|
|
195
|
+
* @since 0.9.57
|
|
196
|
+
* @status stable
|
|
197
|
+
* @related b.agent.orchestrator.create
|
|
198
|
+
*
|
|
199
|
+
* SUBSTRATE-3 — attach an in-process live agent reference to a row
|
|
200
|
+
* that already exists in the persistent registry backend. The
|
|
201
|
+
* canonical boot-phase contract: the *first* process to start a new
|
|
202
|
+
* agent calls `register()` (writes the backend row + holds the live
|
|
203
|
+
* ref); every *subsequent* process that picks up the row from durable
|
|
204
|
+
* storage (cross-orchestrator-restart, multi-process deploy, k8s pod
|
|
205
|
+
* recreate) calls `hydrate(name, agent)` to install its local live
|
|
206
|
+
* ref WITHOUT trying to re-write the backend row (which would refuse
|
|
207
|
+
* with `agent-orchestrator/duplicate`).
|
|
208
|
+
*
|
|
209
|
+
* Throws `agent-orchestrator/not-in-registry` when no backend row
|
|
210
|
+
* exists for `name`. Throws `agent-orchestrator/already-hydrated` if
|
|
211
|
+
* the live ref is already installed (operator's boot phase ran
|
|
212
|
+
* twice).
|
|
213
|
+
*
|
|
214
|
+
* Boot-phase contract:
|
|
215
|
+
* 1. Process A calls `register("tenant-acme.mail", agent, regOpts)`
|
|
216
|
+
* → backend row written; A.liveAgents holds the ref.
|
|
217
|
+
* 2. Process A crashes / redeploys.
|
|
218
|
+
* 3. Process B starts: backend row already exists.
|
|
219
|
+
* 4. Process B walks the registry via `list()` → sees rows it
|
|
220
|
+
* hasn't hydrated yet.
|
|
221
|
+
* 5. For each, Process B reconstructs the agent locally (from its
|
|
222
|
+
* operator config) and calls `hydrate(name, agent)`.
|
|
223
|
+
* 6. `lookup("tenant-acme.mail")` from Process B now returns the
|
|
224
|
+
* live ref instead of throwing `not-hydrated`.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* var rows = await orch.list({});
|
|
228
|
+
* for (var i = 0; i < rows.length; i += 1) {
|
|
229
|
+
* var name = rows[i].name;
|
|
230
|
+
* var agent = buildAgent(rows[i]);
|
|
231
|
+
* await orch.hydrate(name, agent);
|
|
232
|
+
* }
|
|
233
|
+
*/
|
|
234
|
+
async function _hydrate(ctx, name, agent) {
|
|
235
|
+
guardAgentRegistry.validate({ kind: "register", name: name, agentKind: "hydrate" }, {});
|
|
236
|
+
if (!agent || typeof agent !== "object") {
|
|
237
|
+
throw new AgentOrchestratorError("agent-orchestrator/bad-agent",
|
|
238
|
+
"hydrate: agent object required");
|
|
239
|
+
}
|
|
240
|
+
var row = await ctx.backend.get(name);
|
|
241
|
+
if (!row) {
|
|
242
|
+
throw new AgentOrchestratorError("agent-orchestrator/not-in-registry",
|
|
243
|
+
"hydrate: '" + name + "' not in registry backend — call register() first");
|
|
244
|
+
}
|
|
245
|
+
if (ctx.liveAgents.has(name)) {
|
|
246
|
+
throw new AgentOrchestratorError("agent-orchestrator/already-hydrated",
|
|
247
|
+
"hydrate: '" + name + "' already has a live agent ref in this process");
|
|
248
|
+
}
|
|
249
|
+
ctx.liveAgents.set(name, agent);
|
|
250
|
+
_safeAudit(ctx, "agent.orchestrator.hydrated", null, {
|
|
251
|
+
name: name, agentKind: row.kind, tenantId: row.tenantId,
|
|
252
|
+
});
|
|
253
|
+
return { name: name, agentKind: row.kind };
|
|
254
|
+
}
|
|
255
|
+
|
|
145
256
|
// ---- Registry -------------------------------------------------------------
|
|
146
257
|
|
|
147
258
|
async function _register(ctx, name, agent, regOpts) {
|
|
@@ -310,11 +421,41 @@ function _spawnSingleConsumer(ctx, agent, queue, topic, maxConcurrency) {
|
|
|
310
421
|
* var shard = b.agent.orchestrator.shardFor("tenant-acme", 8);
|
|
311
422
|
* // → integer in [0, 8)
|
|
312
423
|
*/
|
|
424
|
+
function _saltedFnvBasis() {
|
|
425
|
+
// SUBSTRATE-20 — salt FNV-1a offset basis with the vault master so
|
|
426
|
+
// an attacker can't engineer tenantIds that all hash to one shard.
|
|
427
|
+
// Vault-less path (single-process tests / dev) falls back to the
|
|
428
|
+
// standard FNV offset basis; production deployments with vault
|
|
429
|
+
// initialized get the salted variant for free.
|
|
430
|
+
if (_saltedFnvBasisCache !== null) return _saltedFnvBasisCache;
|
|
431
|
+
var v;
|
|
432
|
+
try { v = vault(); } catch (_e) { v = null; }
|
|
433
|
+
if (!v || typeof v.getKeysJson !== "function") {
|
|
434
|
+
_saltedFnvBasisCache = 2166136261; // allow:raw-byte-literal — FNV-1a offset basis (vault-less fallback)
|
|
435
|
+
return _saltedFnvBasisCache;
|
|
436
|
+
}
|
|
437
|
+
var keysJson;
|
|
438
|
+
try { keysJson = v.getKeysJson(); }
|
|
439
|
+
catch (_e) {
|
|
440
|
+
_saltedFnvBasisCache = 2166136261; // allow:raw-byte-literal — FNV-1a offset basis (vault-init-pending fallback)
|
|
441
|
+
return _saltedFnvBasisCache;
|
|
442
|
+
}
|
|
443
|
+
var hashHex = bCrypto.sha3Hash(keysJson);
|
|
444
|
+
// Read the first 32 bits as the salt; mix into the offset basis via
|
|
445
|
+
// XOR so the distribution properties of FNV are preserved.
|
|
446
|
+
var saltBuf = Buffer.from(hashHex.slice(0, 8), "hex"); // allow:raw-byte-literal — 32-bit prefix of SHA3-512 hex (4 bytes = 8 hex chars)
|
|
447
|
+
var salt = saltBuf.readUInt32BE(0);
|
|
448
|
+
_saltedFnvBasisCache = ((2166136261 ^ salt) >>> 0); // allow:raw-byte-literal — FNV-1a offset basis (vault-salted)
|
|
449
|
+
return _saltedFnvBasisCache;
|
|
450
|
+
}
|
|
451
|
+
|
|
313
452
|
function shardFor(shardKey, shards) {
|
|
314
453
|
if (typeof shardKey !== "string" || shardKey.length === 0) return 0;
|
|
315
454
|
if (shards <= 1) return 0;
|
|
316
|
-
// FNV-1a 32-bit — fast + good distribution for short keys
|
|
317
|
-
|
|
455
|
+
// FNV-1a 32-bit — fast + good distribution for short keys; salted
|
|
456
|
+
// offset basis defends algorithmic-complexity DoS via attacker-
|
|
457
|
+
// chosen tenantIds. See _saltedFnvBasis above.
|
|
458
|
+
var h = _saltedFnvBasis();
|
|
318
459
|
for (var i = 0; i < shardKey.length; i += 1) {
|
|
319
460
|
h ^= shardKey.charCodeAt(i);
|
|
320
461
|
h = (h * 16777619) >>> 0; // allow:raw-byte-literal — FNV-1a prime
|
|
@@ -337,13 +478,21 @@ async function _elect(ctx, args) {
|
|
|
337
478
|
if (!isClusterMode) {
|
|
338
479
|
// Single-process trivial leader.
|
|
339
480
|
var elec = { isLeader: true, fencingToken: 1, resource: args.resource };
|
|
340
|
-
ctx.elections.set(args.resource, elec);
|
|
481
|
+
if (ctx.cacheElections) ctx.elections.set(args.resource, elec);
|
|
341
482
|
_safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
|
|
342
483
|
resource: args.resource, mode: "single-process",
|
|
343
484
|
});
|
|
344
485
|
return elec;
|
|
345
486
|
}
|
|
346
|
-
//
|
|
487
|
+
// SUBSTRATE-9 — cluster mode: ALWAYS query truth from b.cluster.
|
|
488
|
+
// The onTransition handler installed in create() invalidates the
|
|
489
|
+
// cache on every lease event, so a cache hit here is safe (it
|
|
490
|
+
// means no lease event has fired since the last query). But the
|
|
491
|
+
// cache hit MUST be invalidated by a transition first; we never
|
|
492
|
+
// return stale isLeader:true after a lease-lost without re-asking.
|
|
493
|
+
if (ctx.cacheElections && ctx.elections.has(args.resource)) {
|
|
494
|
+
return ctx.elections.get(args.resource);
|
|
495
|
+
}
|
|
347
496
|
var leaderRow = null;
|
|
348
497
|
try { leaderRow = await ctx.cluster.currentLeader(); } catch (_e) { leaderRow = null; }
|
|
349
498
|
var amLeader = false;
|
|
@@ -358,7 +507,7 @@ async function _elect(ctx, args) {
|
|
|
358
507
|
resource: args.resource,
|
|
359
508
|
leaderId: leaderRow && leaderRow.nodeId ? leaderRow.nodeId : null,
|
|
360
509
|
};
|
|
361
|
-
ctx.elections.set(args.resource, elec2);
|
|
510
|
+
if (ctx.cacheElections) ctx.elections.set(args.resource, elec2);
|
|
362
511
|
_safeAudit(ctx, "agent.orchestrator.elected", args.actor, {
|
|
363
512
|
resource: args.resource, mode: "cluster",
|
|
364
513
|
amLeader: amLeader, leaderId: elec2.leaderId,
|
|
@@ -373,20 +522,122 @@ async function _drain(ctx, args) {
|
|
|
373
522
|
var timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : DEFAULT_DRAIN_TIMEOUT_MS;
|
|
374
523
|
var drained = 0;
|
|
375
524
|
var startedAt = Date.now();
|
|
376
|
-
|
|
525
|
+
var perConsumerMs = ctx.perConsumerStopMs;
|
|
526
|
+
// SUBSTRATE-8 — drain phases:
|
|
527
|
+
// 1. set ctx.draining so streams emit drain-markers + new task
|
|
528
|
+
// dispatches refuse (consumers re-check on every envelope).
|
|
529
|
+
// 2. stop each consumer with BUG-6 per-consumer timeout race —
|
|
530
|
+
// one hung consumer can't block the full drain budget.
|
|
531
|
+
// 3. quiesce in-flight: poll outbox.pendingCount + sagaInFlightCount
|
|
532
|
+
// until 0 OR remaining-budget-ms elapses.
|
|
533
|
+
// 4. flush pubsub if operator wired it (delivers buffered events).
|
|
534
|
+
// ---- Phase 2: stop consumers (each capped) ----
|
|
377
535
|
for (var i = 0; i < ctx.spawnedConsumers.length; i += 1) {
|
|
536
|
+
var remaining = timeoutMs - (Date.now() - startedAt);
|
|
537
|
+
if (remaining <= 0) break;
|
|
378
538
|
var c = ctx.spawnedConsumers[i];
|
|
379
|
-
|
|
380
|
-
|
|
539
|
+
var consumerBudget = Math.min(perConsumerMs, remaining);
|
|
540
|
+
try {
|
|
541
|
+
await _raceTimeout(c.stop(), consumerBudget,
|
|
542
|
+
"consumer '" + c.topic + "' stop");
|
|
543
|
+
drained += 1;
|
|
544
|
+
} catch (e) {
|
|
545
|
+
_safeAudit(ctx, "agent.orchestrator.consumer_stop_timeout", null, {
|
|
546
|
+
topic: c.topic, budgetMs: consumerBudget,
|
|
547
|
+
reason: (e && e.message) || String(e),
|
|
548
|
+
});
|
|
549
|
+
// Continue with next consumer — one hung shouldn't strand the
|
|
550
|
+
// rest. The hung work will be reaped at process exit.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- Phase 3: quiesce in-flight work ----
|
|
555
|
+
var inFlightQuiescent = await _quiesceInFlight(ctx, startedAt, timeoutMs);
|
|
556
|
+
|
|
557
|
+
// ---- Phase 4: pubsub flush (optional) ----
|
|
558
|
+
if (ctx.pubsubFlush) {
|
|
559
|
+
var flushRemaining = timeoutMs - (Date.now() - startedAt);
|
|
560
|
+
if (flushRemaining > 0) {
|
|
561
|
+
try {
|
|
562
|
+
await _raceTimeout(ctx.pubsubFlush(), flushRemaining, "pubsub flush");
|
|
563
|
+
} catch (e) {
|
|
564
|
+
_safeAudit(ctx, "agent.orchestrator.pubsub_flush_timeout", null, {
|
|
565
|
+
reason: (e && e.message) || String(e),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
381
569
|
}
|
|
570
|
+
|
|
382
571
|
// Streams: signal each to wrap up (the streams check ctx.draining
|
|
383
572
|
// and emit a drain-marker themselves; orchestrator just sets the flag).
|
|
384
573
|
var streamCount = ctx.streams.size;
|
|
385
574
|
_safeAudit(ctx, "agent.orchestrator.drained", null, {
|
|
386
575
|
drainedConsumers: drained, totalConsumers: ctx.spawnedConsumers.length,
|
|
387
576
|
streamCount: streamCount, elapsedMs: Date.now() - startedAt,
|
|
577
|
+
inFlightQuiescent: inFlightQuiescent,
|
|
578
|
+
});
|
|
579
|
+
return {
|
|
580
|
+
drained: drained,
|
|
581
|
+
elapsedMs: Date.now() - startedAt,
|
|
582
|
+
inFlightQuiescent: inFlightQuiescent,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function _raceTimeout(p, budgetMs, label) {
|
|
587
|
+
// Promise.race against a setTimeout. Pure-JS, no `b.safeAsync.withTimeout`
|
|
588
|
+
// dependency to avoid a load-time circular require. Note: a rejected
|
|
589
|
+
// race doesn't cancel the original promise (Node has no cancellation
|
|
590
|
+
// primitive); the consumer's stop() keeps running in the background
|
|
591
|
+
// and the orchestrator continues. This is the right behavior for
|
|
592
|
+
// drain — best-effort partial quiesce is better than hanging on one
|
|
593
|
+
// misbehaving consumer.
|
|
594
|
+
return new Promise(function (resolve, reject) {
|
|
595
|
+
var settled = false;
|
|
596
|
+
var t = setTimeout(function () {
|
|
597
|
+
if (settled) return;
|
|
598
|
+
settled = true;
|
|
599
|
+
reject(new AgentOrchestratorError("agent-orchestrator/drain-timeout",
|
|
600
|
+
label + " did not finish within " + budgetMs + "ms"));
|
|
601
|
+
}, budgetMs);
|
|
602
|
+
// The setTimeout is the timeout signal: when stop() never resolves,
|
|
603
|
+
// this timer is the ONLY event-loop work tracking drain's progress.
|
|
604
|
+
// Unref'ing it would let Node exit before the timer fires, leaving
|
|
605
|
+
// the awaiting drain() promise pending forever (process exits while
|
|
606
|
+
// the caller's await chain has no driver).
|
|
607
|
+
Promise.resolve(p).then(
|
|
608
|
+
function (v) { if (!settled) { settled = true; clearTimeout(t); resolve(v); } },
|
|
609
|
+
function (e) { if (!settled) { settled = true; clearTimeout(t); reject(e); } }
|
|
610
|
+
);
|
|
388
611
|
});
|
|
389
|
-
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function _quiesceInFlight(ctx, startedAt, timeoutMs) {
|
|
615
|
+
// Outbox + saga in-flight quiesce loop. Polls every 50ms (cheap;
|
|
616
|
+
// we already paid the consumer-stop budget so this is mostly a
|
|
617
|
+
// few ticks of waiting for the publisher to mark in-flight rows
|
|
618
|
+
// 'published'). Returns true if quiescent, false on timeout.
|
|
619
|
+
if (!ctx.outbox && !ctx.sagaInFlightCount) return true;
|
|
620
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
621
|
+
var anyInFlight = false;
|
|
622
|
+
if (ctx.outbox && typeof ctx.outbox.pendingCount === "function") {
|
|
623
|
+
var pending;
|
|
624
|
+
try { pending = await ctx.outbox.pendingCount(); }
|
|
625
|
+
catch (_e) { pending = 0; }
|
|
626
|
+
if (pending > 0) anyInFlight = true;
|
|
627
|
+
}
|
|
628
|
+
if (ctx.sagaInFlightCount) {
|
|
629
|
+
var sagaPending;
|
|
630
|
+
try { sagaPending = await ctx.sagaInFlightCount(); }
|
|
631
|
+
catch (_e) { sagaPending = 0; }
|
|
632
|
+
if (sagaPending > 0) anyInFlight = true;
|
|
633
|
+
}
|
|
634
|
+
if (!anyInFlight) return true;
|
|
635
|
+
await new Promise(function (r) {
|
|
636
|
+
var t = setTimeout(r, 50); // allow:raw-byte-literal — 50ms in-flight poll interval
|
|
637
|
+
if (t && typeof t.unref === "function") t.unref();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
390
641
|
}
|
|
391
642
|
|
|
392
643
|
// ---- Streams (v0.9.23 substrate hook) -------------------------------------
|
|
@@ -460,4 +711,7 @@ module.exports = {
|
|
|
460
711
|
guards: {
|
|
461
712
|
registry: guardAgentRegistry,
|
|
462
713
|
},
|
|
714
|
+
// Test-only — flush the salted FNV basis cache so a vault reset
|
|
715
|
+
// between tests forces re-derivation.
|
|
716
|
+
_resetForTest: function () { _saltedFnvBasisCache = null; },
|
|
463
717
|
};
|
|
@@ -51,16 +51,90 @@
|
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
53
|
var lazyRequire = require("./lazy-require");
|
|
54
|
+
var nodeCrypto = require("node:crypto");
|
|
54
55
|
var { defineClass } = require("./framework-error");
|
|
55
56
|
var guardPostureChain = require("./guard-posture-chain");
|
|
56
57
|
var agentAudit = require("./agent-audit");
|
|
58
|
+
var safeJson = require("./safe-json");
|
|
59
|
+
var bCrypto = require("./crypto");
|
|
57
60
|
|
|
58
61
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
62
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
59
63
|
|
|
60
64
|
var AgentPostureChainError = defineClass("AgentPostureChainError", { alwaysPermanent: true });
|
|
61
65
|
|
|
62
66
|
var BUILTIN_REGIMES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
|
|
63
67
|
|
|
68
|
+
// SUBSTRATE-10 — envelope MAC vocabulary. Cross-process envelope
|
|
69
|
+
// integrity: an attacker with queue / event-bus write access who
|
|
70
|
+
// strips postureSet to [] and re-sends a saga / sub-agent envelope
|
|
71
|
+
// can bypass the downgrade refusal in _validate (which only checks
|
|
72
|
+
// SHAPE, not authenticity). Defense is a keyed MAC over the canonical
|
|
73
|
+
// envelope bytes, computed at appendHop and verified at validate.
|
|
74
|
+
var ENVELOPE_MAC_LABEL = "blamejs.agent.postureChain/v1";
|
|
75
|
+
var ENVELOPE_MAC_KEY_BYTES = 32; // allow:raw-byte-literal — HMAC-SHA3-512 keyed bytes
|
|
76
|
+
// SUBSTRATE-21 — hop count cap defends infinite recursion across
|
|
77
|
+
// agent delegation. 16 is the spec default; operators can lower via
|
|
78
|
+
// opts.maxHopCount but never raise (audit fan-out without a cap is a
|
|
79
|
+
// DoS class).
|
|
80
|
+
var DEFAULT_MAX_HOP_COUNT = 16; // allow:raw-byte-literal — hop count cap
|
|
81
|
+
var _macKeyCache = null; // memoized per-vault-master key
|
|
82
|
+
|
|
83
|
+
function _resolveMacKey() {
|
|
84
|
+
// Lazy derivation keyed off the vault master. Operator rotating
|
|
85
|
+
// vault keys invalidates every in-flight MAC — desired property.
|
|
86
|
+
// Memoization is process-local; if vault rotates within the same
|
|
87
|
+
// process the operator restarts (vault rotation already implies it).
|
|
88
|
+
if (_macKeyCache) return _macKeyCache;
|
|
89
|
+
var v;
|
|
90
|
+
try { v = vault(); } catch (_e) { v = null; }
|
|
91
|
+
if (!v || typeof v.getKeysJson !== "function") {
|
|
92
|
+
throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
|
|
93
|
+
"envelope MAC: vault must be initialized before posture-chain envelopes can be authenticated " +
|
|
94
|
+
"(operator wires b.vault.init() at boot)");
|
|
95
|
+
}
|
|
96
|
+
var keysJson;
|
|
97
|
+
try { keysJson = v.getKeysJson(); }
|
|
98
|
+
catch (e) {
|
|
99
|
+
throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
|
|
100
|
+
"envelope MAC: vault.getKeysJson threw — " + (e && e.message ? e.message : String(e)));
|
|
101
|
+
}
|
|
102
|
+
var rootBytes = Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
|
|
103
|
+
var input = Buffer.concat([
|
|
104
|
+
Buffer.from(ENVELOPE_MAC_LABEL, "utf8"),
|
|
105
|
+
Buffer.from([0x00]),
|
|
106
|
+
rootBytes,
|
|
107
|
+
]);
|
|
108
|
+
_macKeyCache = bCrypto.kdf(input, ENVELOPE_MAC_KEY_BYTES);
|
|
109
|
+
return _macKeyCache;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _envelopeMacBytes(envelope) {
|
|
113
|
+
// Sign every field that downstream consumers verify off the wire,
|
|
114
|
+
// except the `_mac` field itself. SUBSTRATE-21 — also includes
|
|
115
|
+
// hopCount + chainTrail so a hostile rewriter can't roll back the
|
|
116
|
+
// trail to evade the cap.
|
|
117
|
+
var payload = {
|
|
118
|
+
postureSet: Array.isArray(envelope.postureSet) ? envelope.postureSet.slice().sort() : [],
|
|
119
|
+
chainTrail: Array.isArray(envelope.chainTrail) ? envelope.chainTrail.slice() : [],
|
|
120
|
+
enteredAt: Array.isArray(envelope.enteredAt) ? envelope.enteredAt.slice() : [],
|
|
121
|
+
hopCount: typeof envelope.hopCount === "number" ? envelope.hopCount : 0,
|
|
122
|
+
};
|
|
123
|
+
return Buffer.from(safeJson.canonical(payload), "utf8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _signEnvelope(envelope) {
|
|
127
|
+
var key = _resolveMacKey();
|
|
128
|
+
var mac = nodeCrypto.createHmac("sha3-512", key).update(_envelopeMacBytes(envelope)).digest();
|
|
129
|
+
return mac.toString("base64");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _verifyEnvelopeMac(envelope) {
|
|
133
|
+
if (typeof envelope._mac !== "string" || envelope._mac.length === 0) return false;
|
|
134
|
+
var expected = _signEnvelope(envelope);
|
|
135
|
+
return bCrypto.timingSafeEqual(envelope._mac, expected);
|
|
136
|
+
}
|
|
137
|
+
|
|
64
138
|
/**
|
|
65
139
|
* @primitive b.agent.postureChain.create
|
|
66
140
|
* @signature b.agent.postureChain.create(opts)
|
|
@@ -84,14 +158,32 @@ function create(opts) {
|
|
|
84
158
|
var auditImpl = opts.audit || audit();
|
|
85
159
|
var declaredRegimes = Object.create(null);
|
|
86
160
|
for (var i = 0; i < BUILTIN_REGIMES.length; i += 1) declaredRegimes[BUILTIN_REGIMES[i]] = true;
|
|
161
|
+
// allow:numeric-opt-Infinity — operator opt clamped to [1, DEFAULT_MAX_HOP_COUNT]; bad input falls back to default
|
|
162
|
+
var maxHopCount = typeof opts.maxHopCount === "number" && opts.maxHopCount > 0 &&
|
|
163
|
+
opts.maxHopCount <= DEFAULT_MAX_HOP_COUNT
|
|
164
|
+
? Math.floor(opts.maxHopCount)
|
|
165
|
+
: DEFAULT_MAX_HOP_COUNT;
|
|
166
|
+
// SUBSTRATE-10 escape hatch — only operator-confirmed single-process
|
|
167
|
+
// unit tests should opt out of envelope MAC. Production / multi-
|
|
168
|
+
// process / queue-spanning deployments leave the default on; the
|
|
169
|
+
// gate audit-emits when bypassed so the posture is visible.
|
|
170
|
+
var requireMac = opts.requireMac !== false;
|
|
171
|
+
var ctx = {
|
|
172
|
+
audit: auditImpl,
|
|
173
|
+
maxHopCount: maxHopCount,
|
|
174
|
+
requireMac: requireMac,
|
|
175
|
+
};
|
|
87
176
|
return {
|
|
88
177
|
declareRegime: function (name) { return _declareRegime(declaredRegimes, name); },
|
|
89
178
|
isSubset: function (targetSet, sourceSet) { return _isSubset(targetSet, sourceSet); },
|
|
90
179
|
union: function () { return _union.apply(null, arguments); },
|
|
91
180
|
canDelegate: function (sourceSet, targetSet, method) { return _canDelegate(sourceSet, targetSet, method, auditImpl); },
|
|
92
|
-
appendHop: function (envelope, hopName) { return _appendHop(envelope, hopName); },
|
|
93
|
-
validate: function (envelope, agentPostureSet) { return _validate(envelope, agentPostureSet
|
|
181
|
+
appendHop: function (envelope, hopName) { return _appendHop(ctx, envelope, hopName); },
|
|
182
|
+
validate: function (envelope, agentPostureSet) { return _validate(ctx, envelope, agentPostureSet); },
|
|
183
|
+
sign: function (envelope) { return _signEnvelope(envelope); },
|
|
184
|
+
verify: function (envelope) { return _verifyEnvelopeMac(envelope); },
|
|
94
185
|
REGIMES: Object.freeze(Object.keys(declaredRegimes)),
|
|
186
|
+
MAX_HOP_COUNT: maxHopCount,
|
|
95
187
|
AgentPostureChainError: AgentPostureChainError,
|
|
96
188
|
_declaredRegimes: declaredRegimes,
|
|
97
189
|
};
|
|
@@ -155,7 +247,7 @@ function _missing(targetSet, sourceSet) {
|
|
|
155
247
|
return out;
|
|
156
248
|
}
|
|
157
249
|
|
|
158
|
-
function _appendHop(envelope, hopName) {
|
|
250
|
+
function _appendHop(ctx, envelope, hopName) {
|
|
159
251
|
if (!envelope || typeof envelope !== "object") {
|
|
160
252
|
throw new AgentPostureChainError("agent-posture-chain/bad-envelope",
|
|
161
253
|
"appendHop: envelope required");
|
|
@@ -165,6 +257,19 @@ function _appendHop(envelope, hopName) {
|
|
|
165
257
|
"appendHop: hopName must be a non-empty string");
|
|
166
258
|
}
|
|
167
259
|
var trail = Array.isArray(envelope.chainTrail) ? envelope.chainTrail.slice() : [];
|
|
260
|
+
// SUBSTRATE-21 — cap enforced BEFORE the push so the hop-cap throw
|
|
261
|
+
// fires consistently regardless of whether the operator inspects
|
|
262
|
+
// trail.length first. Cap is a hard refusal (no truncation) because
|
|
263
|
+
// a silently-dropped hop loses audit provenance for the call.
|
|
264
|
+
if (trail.length >= ctx.maxHopCount) {
|
|
265
|
+
agentAudit.safeAudit(ctx.audit, "agent.posture_chain.hop_cap_refused", null, {
|
|
266
|
+
hopName: hopName, hopCount: trail.length, maxHopCount: ctx.maxHopCount,
|
|
267
|
+
chainTrail: trail,
|
|
268
|
+
});
|
|
269
|
+
throw new AgentPostureChainError("agent-posture-chain/hop-cap-exceeded",
|
|
270
|
+
"appendHop: chain trail has " + trail.length + " hops; cap is " + ctx.maxHopCount +
|
|
271
|
+
" — refusing to extend (operator delegation cycle?)");
|
|
272
|
+
}
|
|
168
273
|
trail.push(hopName);
|
|
169
274
|
var enteredAt = Array.isArray(envelope.enteredAt) ? envelope.enteredAt.slice() : [];
|
|
170
275
|
enteredAt.push(Date.now());
|
|
@@ -174,11 +279,61 @@ function _appendHop(envelope, hopName) {
|
|
|
174
279
|
hopCount: trail.length,
|
|
175
280
|
});
|
|
176
281
|
guardPostureChain.validate(newEnvelope);
|
|
282
|
+
// SUBSTRATE-10 — sign at every hop. Verify-side enforces requireMac.
|
|
283
|
+
// ctx.requireMac=false (operator-confirmed test escape hatch) skips
|
|
284
|
+
// the sign so a vault-less test path still works.
|
|
285
|
+
if (ctx.requireMac) {
|
|
286
|
+
try {
|
|
287
|
+
newEnvelope._mac = _signEnvelope(newEnvelope);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
// Vault not initialized at boot — surface the error to the
|
|
290
|
+
// operator. Without the MAC the envelope is unauthenticated and
|
|
291
|
+
// every downstream _validate would refuse it; better to refuse
|
|
292
|
+
// here with a clear message.
|
|
293
|
+
throw new AgentPostureChainError("agent-posture-chain/mac-sign-failed",
|
|
294
|
+
"appendHop: envelope MAC sign failed — " + (e && e.message ? e.message : String(e)) +
|
|
295
|
+
" — operator wires b.vault.init() before agent-posture-chain.appendHop OR " +
|
|
296
|
+
"passes create({ requireMac: false }) for vault-less unit tests");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
177
299
|
return newEnvelope;
|
|
178
300
|
}
|
|
179
301
|
|
|
180
|
-
function _validate(envelope, agentPostureSet
|
|
302
|
+
function _validate(ctx, envelope, agentPostureSet) {
|
|
181
303
|
guardPostureChain.validate(envelope);
|
|
304
|
+
// SUBSTRATE-10 — MAC verification BEFORE any field-based decision so
|
|
305
|
+
// the wire-rewrite attack (postureSet:[] downgrade with valid SHAPE
|
|
306
|
+
// but no integrity binding) is refused. ctx.requireMac=false skips
|
|
307
|
+
// verification and emits an audit so the bypass is visible.
|
|
308
|
+
if (ctx.requireMac) {
|
|
309
|
+
if (typeof envelope._mac !== "string" || envelope._mac.length === 0) {
|
|
310
|
+
agentAudit.safeAudit(ctx.audit, "agent.posture_chain.unauthenticated_envelope", null, {
|
|
311
|
+
chainTrail: envelope.chainTrail, postureSet: envelope.postureSet,
|
|
312
|
+
});
|
|
313
|
+
throw new AgentPostureChainError("agent-posture-chain/missing-mac",
|
|
314
|
+
"validate: envelope is unauthenticated (no _mac field) — refusing under requireMac=true");
|
|
315
|
+
}
|
|
316
|
+
if (!_verifyEnvelopeMac(envelope)) {
|
|
317
|
+
agentAudit.safeAudit(ctx.audit, "agent.posture_chain.mac_verify_failed", null, {
|
|
318
|
+
chainTrail: envelope.chainTrail, postureSet: envelope.postureSet,
|
|
319
|
+
});
|
|
320
|
+
throw new AgentPostureChainError("agent-posture-chain/mac-verify-failed",
|
|
321
|
+
"validate: envelope MAC verification failed — bytes tampered, " +
|
|
322
|
+
"chain trail rewritten, or signed under a different vault keypair");
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
agentAudit.safeAudit(ctx.audit, "agent.posture_chain.mac_skipped", null, {
|
|
326
|
+
chainTrail: envelope.chainTrail,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// SUBSTRATE-21 — hop cap also enforced at validate-time. A hostile
|
|
330
|
+
// envelope might arrive with hopCount > cap if a prior hop's
|
|
331
|
+
// requireMac was off; refuse here regardless.
|
|
332
|
+
if (Array.isArray(envelope.chainTrail) && envelope.chainTrail.length > ctx.maxHopCount) {
|
|
333
|
+
throw new AgentPostureChainError("agent-posture-chain/hop-cap-exceeded",
|
|
334
|
+
"validate: chain trail length " + envelope.chainTrail.length +
|
|
335
|
+
" exceeds cap " + ctx.maxHopCount);
|
|
336
|
+
}
|
|
182
337
|
// The source (envelope) carries a postureSet; the target (the agent
|
|
183
338
|
// we're entering) declares its own posture set. Target must be a
|
|
184
339
|
// superset of source — i.e., the agent covers every regime the
|
|
@@ -186,7 +341,7 @@ function _validate(envelope, agentPostureSet, auditImpl) {
|
|
|
186
341
|
if (Array.isArray(agentPostureSet)) {
|
|
187
342
|
if (!_isSubset(agentPostureSet, envelope.postureSet)) {
|
|
188
343
|
var missing = _missing(agentPostureSet, envelope.postureSet);
|
|
189
|
-
agentAudit.safeAudit(
|
|
344
|
+
agentAudit.safeAudit(ctx.audit, "agent.posture_chain.downgrade_refused", null, {
|
|
190
345
|
sourceSet: envelope.postureSet, targetSet: agentPostureSet, missing: missing,
|
|
191
346
|
chainTrail: envelope.chainTrail,
|
|
192
347
|
});
|
|
@@ -201,8 +356,11 @@ function _validate(envelope, agentPostureSet, auditImpl) {
|
|
|
201
356
|
module.exports = {
|
|
202
357
|
create: create,
|
|
203
358
|
BUILTIN_REGIMES: BUILTIN_REGIMES,
|
|
359
|
+
MAX_HOP_COUNT: DEFAULT_MAX_HOP_COUNT,
|
|
204
360
|
AgentPostureChainError: AgentPostureChainError,
|
|
205
361
|
guards: {
|
|
206
362
|
chain: guardPostureChain,
|
|
207
363
|
},
|
|
364
|
+
// Test-only — flush the memoized MAC key after a vault reset.
|
|
365
|
+
_resetForTest: function () { _macKeyCache = null; },
|
|
208
366
|
};
|