@blamejs/core 0.13.40 → 0.13.41
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 +2 -0
- package/lib/agent-orchestrator.js +39 -0
- package/lib/subject.js +8 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.41 (2026-05-29) — **Agent registry reads can be tenant-scoped; compliance-erasure docs clarify actor is an audit field.** The agent orchestrator's registry reads (list and lookup) gated only on the flat agent-registry:read scope, so any holder could enumerate every tenant's agents and resolve a handle to one — even though the event bus already scopes subscribe and delivery by tenant. The orchestrator now mirrors that: with the new tenantScope option enabled, list returns only the actor's own tenant's agents and lookup refuses a cross-tenant name, unless the actor holds the framework cross-tenant-admin scope. Off by default, so single-tenant deployments are unaffected. Separately, the subject-erasure docs now state explicitly that the recorded actor is an audit field, not authentication — the caller must be authorized upstream. **Added:** *Tenant-scoped agent registry reads (opts.tenantScope)* — `b.agent.orchestrator.create({ tenantScope: true })` now scopes `list` and `lookup` to the calling actor's tenant: `list` filters out agents in other tenants and `lookup` returns null for a cross-tenant name, unless the actor holds the cross-tenant-admin scope (`b.agent.tenant.CROSS_TENANT_ADMIN_SCOPE`). This closes a cross-tenant metadata-enumeration and handle-acquisition path — `agent-registry:read` alone no longer exposes other tenants' agents — and mirrors the tenant scoping the event bus enforces on subscribe and delivery. The option defaults off; existing single-tenant orchestrators behave exactly as before. The `tenantId` argument to `list` remains a convenience filter, distinct from this authorization boundary. **Changed:** *Subject-erasure docs clarify the actor is an audit field, not authentication* — `b.subject.erase` and `b.subject.eraseHard` gate the deletion on operator acknowledgements and the legal-hold registry, not on caller identity. Their documentation now states explicitly that the recorded `actor` is an audit-record field, not authentication — the caller MUST be authenticated and authorized by the route before invoking. No behavior change; this removes an implicit assumption that could otherwise be read as the primitive authorizing the call.
|
|
12
|
+
|
|
11
13
|
- v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
|
|
12
14
|
|
|
13
15
|
- v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
|
|
@@ -64,6 +64,7 @@ var cluster = lazyRequire(function () { return require("./cluster"); }
|
|
|
64
64
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
65
65
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
66
66
|
var safeJson = require("./safe-json");
|
|
67
|
+
var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
|
|
67
68
|
|
|
68
69
|
var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
|
|
69
70
|
|
|
@@ -168,6 +169,12 @@ function create(opts) {
|
|
|
168
169
|
cluster: clusterImpl,
|
|
169
170
|
audit: auditImpl,
|
|
170
171
|
permissions: permissions,
|
|
172
|
+
// When true, registry reads (list / lookup) are scoped to the actor's
|
|
173
|
+
// tenant — an actor only sees / resolves agents in its own tenant
|
|
174
|
+
// unless it holds the cross-tenant-admin scope. Mirrors the tenant
|
|
175
|
+
// scoping agent-event-bus enforces on subscribe / delivery. Off by
|
|
176
|
+
// default (single-tenant deployments are unaffected).
|
|
177
|
+
tenantScope: opts.tenantScope === true,
|
|
171
178
|
spawnedConsumers: [],
|
|
172
179
|
streams: new Map(),
|
|
173
180
|
elections: new Map(),
|
|
@@ -358,6 +365,19 @@ async function _unregister(ctx, name, args) {
|
|
|
358
365
|
async function _lookup(ctx, name, args) {
|
|
359
366
|
guardAgentRegistry.validate({ kind: "lookup", name: name }, {});
|
|
360
367
|
_checkPermission(ctx, args.actor, "agent-registry:read");
|
|
368
|
+
// Tenant-scope gate: the row's declared tenant gates access even to a
|
|
369
|
+
// live in-process ref, so an actor can't acquire a handle to another
|
|
370
|
+
// tenant's agent. Consult the backend row (it exists as a metadata
|
|
371
|
+
// declaration even where a live ref is hydrated).
|
|
372
|
+
if (ctx.tenantScope) {
|
|
373
|
+
var sealedRow = await ctx.backend.get(name);
|
|
374
|
+
var declRow = sealedRow ? _unsealRegistryRow(sealedRow) : null;
|
|
375
|
+
if (declRow && !_tenantAllows(ctx, args.actor, declRow.tenantId)) {
|
|
376
|
+
_safeAudit(ctx, "agent.orchestrator.lookup_denied", args.actor,
|
|
377
|
+
{ name: name, reason: "cross-tenant" });
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
361
381
|
// Live agent ref lives in-process; the backend row exists only as
|
|
362
382
|
// a metadata declaration. In multi-process deployments each process
|
|
363
383
|
// hydrates its own liveAgents map by calling register() locally.
|
|
@@ -383,6 +403,10 @@ async function _list(ctx, args) {
|
|
|
383
403
|
return rows.filter(function (r) {
|
|
384
404
|
if (args.kind && r.kind !== args.kind) return false;
|
|
385
405
|
if (args.tenantId && r.tenantId !== args.tenantId) return false;
|
|
406
|
+
// Tenant-scope gate: drop rows the actor's tenant may not see, so
|
|
407
|
+
// enumeration can't disclose other tenants' agents. The args.tenantId
|
|
408
|
+
// above is a caller-supplied FILTER, not an authorization boundary.
|
|
409
|
+
if (!_tenantAllows(ctx, args.actor, r.tenantId)) return false;
|
|
386
410
|
return true;
|
|
387
411
|
}).map(function (r) {
|
|
388
412
|
return {
|
|
@@ -753,6 +777,21 @@ function _checkPermission(ctx, actor, scope) {
|
|
|
753
777
|
}
|
|
754
778
|
}
|
|
755
779
|
|
|
780
|
+
// Tenant-scope gate for registry reads. Returns true when the actor may
|
|
781
|
+
// see / resolve an agent row in `rowTenantId`: scoping disabled, the actor
|
|
782
|
+
// holds the cross-tenant-admin scope, or the actor's tenant matches the
|
|
783
|
+
// row's. Mirrors agent-tenant's CROSS_TENANT_ADMIN_SCOPE check so registry
|
|
784
|
+
// enumeration can't leak agents (or hand out live refs) across tenants.
|
|
785
|
+
function _tenantAllows(ctx, actor, rowTenantId) {
|
|
786
|
+
if (!ctx.tenantScope) return true;
|
|
787
|
+
if (ctx.permissions && actor &&
|
|
788
|
+
ctx.permissions.check(actor, agentTenant().CROSS_TENANT_ADMIN_SCOPE)) {
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
var actorTenant = (actor && actor.tenantId) || null;
|
|
792
|
+
return actorTenant !== null && actorTenant === (rowTenantId || null);
|
|
793
|
+
}
|
|
794
|
+
|
|
756
795
|
function _safeAudit(ctx, action, actor, metadata) {
|
|
757
796
|
agentAudit.safeAudit(ctx.audit, action, actor, metadata);
|
|
758
797
|
}
|
package/lib/subject.js
CHANGED
|
@@ -257,6 +257,11 @@ function rectify(subjectId, opts) {
|
|
|
257
257
|
* override an active hold (FRCP Rule 26/37(e), GDPR Art 17(3)(e),
|
|
258
258
|
* SEC Rule 17a-4, HIPAA §164.530(j)(2)).
|
|
259
259
|
*
|
|
260
|
+
* Security: any `actor` recorded here is an audit-record field, NOT
|
|
261
|
+
* authentication. This primitive gates the deletion on acknowledgements
|
|
262
|
+
* and the legal-hold registry, not on caller identity — the caller MUST be
|
|
263
|
+
* authenticated and authorized by your route before invoking.
|
|
264
|
+
*
|
|
260
265
|
* Returns `{ rowsDeleted, perTable }`. Use `b.subject.eraseHard` when
|
|
261
266
|
* residual ciphertext in WAL / replicas / backups must also be made
|
|
262
267
|
* undecryptable.
|
|
@@ -383,7 +388,9 @@ function erase(subjectId, opts) {
|
|
|
383
388
|
* Art. 17 erasure shape the framework offers.
|
|
384
389
|
*
|
|
385
390
|
* Same legal-hold + acknowledgement gates as `b.subject.erase`.
|
|
386
|
-
*
|
|
391
|
+
* Security: the `actor` is an audit-record field, not authentication —
|
|
392
|
+
* authorize the caller upstream. Leader-only.
|
|
393
|
+
* Returns `{ rowsDeleted, perRowKeysDestroyed, perTable }`.
|
|
387
394
|
*
|
|
388
395
|
* @opts
|
|
389
396
|
* reason: string, // ticket reference recorded in the audit event
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:857f3ba9-146d-4174-ba61-fed4e89f9c16",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-29T18:38:58.148Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.41",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.41",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.41",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.41",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|