@blamejs/core 0.9.21 → 0.9.23

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.23 (2026-05-14) — **CodeQL alert sweep — 30 alerts closed across 6 rule classes + wiki @primitive validator extracted into static gates.** Standalone substrate slice inserted into the v0.9.21–v0.9.30 playbook; downstream substrate slices shift +1 (stream now v0.9.24, eventBus v0.9.25, tenant v0.9.26, saga v0.9.27, postureChain v0.9.28, trace v0.9.29, snapshot v0.9.30). The 30 alerts were regressions of v0.9.18's earlier batch fix — v0.9.15's framework-wide `require()`-binding rename sweep (184 renames across 108 files) shifted line numbers and the suppression comments stopped tracking. (1) **`js/file-system-race` (10 sites)** — `lib/atomic-file.js:_readSyncCore` migrated from `statSync`+`readFileSync` to the canonical TOCTOU-safe-read scaffold (open fd → `fstatSync` → `readSync` loop → `closeSync` in finally; ENOENT now surfaces from `openSync`); `lib/restore-rollback.js` marker write switched to `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` + EEXIST tolerance; `lib/network-tls.js` new `_readPathFile` fd-based reader; `lib/backup/bundle.js` fd-narrowed read with short-read detection; `lib/static.js` content-safety gate converted to `fsp.open` filehandle pattern; `lib/vault/seal-pem-file.js`, `lib/atomic-file.js:fsyncDir`, `test/30-chain.js`, `examples/wiki/test/validate-{env,cli}-snapshot.js`, `examples/wiki/lib/source-doc-parser.js` got suppression refresh OR fd-narrowed refactor per shape. (2) **`js/insecure-temporary-file` (6 sites)** — `lib/mtls-ca.js` new `_writeExclusive` helper using `openSync(..., "wx", mode)` + `writeSync` + `fsyncSync`; `lib/vault/rotate.js` + `lib/http-client.js` got suppression refresh pointing at the operator-supplied stagingDir / 64-bit CSPRNG suffix defenses. (3) **`js/path-injection` (2 sites in `lib/static.js`)** — suppression refresh pointing at the upstream `_resolveSafe` sandbox check at line 181 (lexical resolve + `startsWith(rootResolved + sep)` + realpath escape guard + guardFilename gate). (4) **`js/remote-property-injection` (7 sites)** — `lib/middleware/csrf-protect.js` cookie-parse output + `lib/websocket.js` `_parseExtensionHeader` params switched to `Object.create(null)` so attacker-controlled keys have no prototype chain to pollute; `lib/middleware/body-parser.js` multipart fields got suppression refresh pointing at the POISONED_KEYS gate at line 867; `test/40-consumers.js` + `test/00-primitives.js` test fixtures suppressed with `test/` scope justification. (5) **`PinnedDependenciesID` (2 workflow files)** — every `uses:` line was already SHA-pinned per v0.9.18; the remaining alerts pointed at the `npm install --no-audit --no-fund` step in `.github/workflows/npm-publish.yml` + `ci.yml`; added explicit `name@version` specifiers (esbuild + postject, mirroring `package.json`'s exact pins) so CodeQL recognizes the install as pinned without committing a lockfile (which CLAUDE.md hard rule §1 forbids — vendored stack, zero npm runtime deps). (6) **`js/regex/missing-regexp-anchor` (1 site)** — `scripts/build-vendored-sbom.js:42` host-extractor regex anchored to `^https?://github.com/` so an attacker-controlled `entry.source` containing `github.com` as a path or query substring can't misdirect purl dispatch into the github branch. (7) **Wiki @primitive validator extracted into static gates** — the `@related namespace-not-primitive` nit fired on PRs #50 (v0.9.20), #51 (v0.9.21), AND #52 (v0.9.22) — same class, three rediscoveries because the validator only ran in CI's wiki-e2e gate (~90s round-trip), never in local pre-push. Extraction: `examples/wiki/lib/source-comment-block-validator.js` (shared engine, pure module, no side effects) + `scripts/validate-source-comment-blocks.js` (framework-level standalone wrapper, runs in 419–466ms, no `@blamejs/core` / vault / DB / network dependencies). Existing wiki-e2e gate refactored to delegate to the shared engine; CI invocation unchanged. CLAUDE.md release workflow step 4 (static gates) now lists `node scripts/validate-source-comment-blocks.js` after `codebase-patterns.test.js`. Each suppression comment references the active defense by file:line so future CodeQL re-runs OR future readers know which suppression applies. Multi-agent fan-out — 4 parallel subagents in isolated git worktrees per rule class.
12
+ - v0.9.22 (2026-05-14) — **`b.agent.idempotency` — cross-dispatch idempotency keys honored at every agent consumer boundary; JMAP retry-safe semantics from day one.** Second slice of the v0.9.21–v0.9.29 substrate playbook. (1) **`b.agent.idempotency.create({ store, audit, ttlMs, maxResultBytes, fingerprintArgs })`** — pluggable backing store via `{ get, put, delete, gc }`; in-memory default ships for single-process deployments, operator wires durable backends (sqlite-backed adapter or external). Surface: `instance.get(method, actorId, key)` returns cached envelope `{ result, firstAt, lastReplayedAt, replayCount, requestFingerprint }` or `null`; `instance.put(method, actorId, key, result, { args, requestFingerprint })` serializes via `b.safeJson.stringify` + persists with TTL + refuses key-reuse-different-args via the request-fingerprint (sha3-512 of args sans key) check; `instance.invalidate(method, actorId, key)` is the saga-compensation escape hatch; `instance.gc({ olderThanMs })` for periodic cleanup. (2) **Keys hashed at the boundary** — operator-supplied keys never reach disk in raw form; namespace-hashed via `b.crypto.namespaceHash("agent.idempotency", method + "\\0" + actorId + "\\0" + key)`. Cross-actor + cross-method isolation by construction (an attacker can't replay another actor's mutation by guessing the key). (3) **Replay tracking** — every cache hit increments `replayCount` + bumps `lastReplayedAt`; audit emits `agent.idempotency.replay` with the truncated actor-id hash + first/replay timestamps so operator pipelines surface retry storms. (4) **`b.guardIdempotencyKey`** — operator-supplied key shape validator. Refuses oversized (default 256 bytes), control chars (C0/NUL/DEL — defends audit-log injection), slash + backslash (defends operators routing keys through filesystem paths), path-traversal (`..`), non-ASCII under strict (operator-greppable in audit logs across stack boundaries; permissive opts down for legacy Unicode tenant IDs). (5) **JSON round-trip test helper** — new `test/helpers/json-round-trip.js` exporting `assertJsonRoundTrip(shape, label)`. Catches the bug class Codex flagged on PR #51 (v0.9.21 `register()` stored agent function ref on backend row, lost in DB/JSON serialization). Refuses on function fields, Buffer fields, Date objects, Symbol keys, BigInt values, non-finite numbers, cycles. Applied retroactively to v0.9.21 agent-orchestrator backend row in regression test. (6) **Result size cap** (default 1 MiB per cached entry) — refuses with `agent-idempotency/result-too-big` so operators discover OOM-prone result shapes locally instead of at runtime. Fuzz harness ships in `fuzz/guard-idempotency-key.fuzz.js`; ClusterFuzzLite matrix entries added. Per the substrate playbook in `memory/specs/blamejs-agent-idempotency-spec.md`.
11
13
  - v0.9.21 (2026-05-14) — **`b.agent.orchestrator` — framework-level supervisor for every agent blamejs ships.** First slice of the v0.9.21–v0.9.29 substrate playbook that builds before mail-stack resumes. (1) **`b.agent.orchestrator.create(opts)`** — facade with `register(name, agent, opts)` / `lookup(name)` / `unregister(name)` / `list({ kind, tenantId })` registry, `spawnConsumers({ agent, queue, shards, taskTopic, maxConcurrency })` for sharded-topic dispatch (FNV-1a consistent-hash via `b.agent.orchestrator.shardFor(key, shards)`; per-shard topic suffix `<base>.<shard>`), `elect({ resource })` composing `b.cluster` DB-row leader election (returns `{ isLeader, fencingToken, leaderId }` — single-process deployments get a trivial-leader; cluster deployments delegate), `drain({ timeoutMs })` stopping every spawned consumer + audit-emitting elapsed/count, and `health()` aggregating per-agent + per-consumer + per-election state into one shape ready for `b.middleware.healthcheck`. (2) **Pluggable backend** — `{ get, set, delete, list }` interface; in-memory default ships for single-process deployments, operator wires `b.config.loadDbBacked`-shaped or external for restart-survival. (3) **`b.guardAgentRegistry`** — registry-op shape validator. Refuses non-ASCII agent names (NFC + ASCII-only — operator-greppable in audit logs), path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL), oversized (default 64 bytes), reserved `FRAMEWORK.*` / `ROOT.*` prefix, duplicate-on-register, register without `agentKind`. Ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (4) **Drain phase auto-wires into `b.appShutdown`** when operator supplies an `appShutdown` instance; `SIGTERM` delivers a clean drain of all spawned consumers + stream-registry signal before the process exits. (5) **Stream-registry hook** (`registerStream` / `unregisterStream` / `isDraining`) — substrate for v0.9.23 `b.agent.stream` so async-iterable method variants can check the drain flag and emit drain-markers. Substrate is NOT a process supervisor — process spawn / restart-on-crash / pod scheduling delegate to pm2 / systemd / k8s / Nomad; framework doesn't compete. Fuzz harness ships in `fuzz/guard-agent-registry.fuzz.js`. Per the substrate playbook in `memory/specs/blamejs-agent-orchestrator-spec.md`; v0.9.22 idempotency wires on top next.
12
14
  - v0.9.20 (2026-05-14) — **`b.mail.agent` — the standardization contract for every mail protocol blamejs ships.** Every above-the-wire mail surface (JMAP at v0.9.27, IMAP at v0.9.28, POP3 at v0.9.29, ManageSieve at v0.9.30, the MX listener at v0.9.24, submission at v0.9.25) translates protocol calls into `agent.X(args)`; RBAC, posture enforcement, audit emission, dispatch, and worker isolation are owned at the agent. (1) **`b.mail.agent.create({ store, audit, permissions, posture, identity, dispatch })`** — facade with 23 methods. Read surface (`search` / `fetch` / `thread` / `folders` / `quota`) is backed by v0.9.19 `b.mailStore` and runs immediately. Move surface (`move` / `flag` / `delete`) is backed by the new `mailStore.moveMessages` substrate; soft-delete moves to Trash + tags `\Deleted` (hard expunge wires at v0.9.28 with retention-floor enforcement). Write surface (`compose` / `send` / `reply` / `forward`), Sieve (`sieve.list/put/activate`), identity (`identity.set` / `vacation.set`), MDN (`mdn.send/parse/allowList`), regulated export, and migration `import` throw `mail-agent/not-implemented` with a `wiredAt` tag naming the slice that lights them up (defer-with-condition per the v1-defensible-scope rule). (2) **Dispatch contract** — `dispatch.mode` is `"local"` / `"queue"` / `"auto"`. `local` runs every method in-process. `queue` publishes envelopes to `mail.agent.tasks` via `b.queue.enqueue`; an `agent.consumer({ agent, queue })` running in a dedicated process or replicas across hosts pulls and executes. Posture metadata travels with each envelope; the consumer re-validates against its own posture before unseal so no posture downgrade survives the queue boundary. `auto` routes fast-path ops (`fetch` / `folders` / `flag` / `quota`) locally and heavy ops (`search` / `export`) to queue/workerPool when configured. (3) **Worker isolation** — `dispatch.workerPool` (composes `b.workerPool`) is validated at create-time; the agent reserves `vaultKeyDelivery: "in-worker"` (default) vs `"main-only"` (posture-conditional — HIPAA/PCI/GDPR default to main-only when the worker-script path wires at v0.9.26 Sieve). (4) **`b.mail.agent.consumer`** — the queue-side facade for multi-host load-spreading; carries its own `store` and re-validates posture at the boundary. (5) **Five new guards** through `b.gateContract` — **`b.guardMailQuery`** (search/fetch filter shape: bounded depth/keys/array-length, function/regex/Buffer/cycle refusal, `__proto__` key refusal, projection-column allowlist via `FILTERABLE_COLUMNS`, posture-required actor fields HIPAA→`purposeOfUse` / PCI→`pciScope` / GDPR→`lawfulBasis`); **`b.guardMailCompose`** (draft envelope: identity-vs-From alignment, recipient deduplication, attachment-byte cap default 25 MiB, body shape — exactly one of text/html unless `allowMultipartAlternative`, C0 control-char refusal in headers); **`b.guardMailReply`** (References-chain cap default 100 defends infinite-loop forwards, In-Reply-To continuity per RFC 5322 §3.6.4 — last References must match In-Reply-To, quoted-original byte cap, forwarded-attachment cardinality cap); **`b.guardMailMove`** (system-folder allowlist for INBOX/Sent/Drafts/Trash/Junk/Archive, admin-scope or `allowedFolders` gate for arbitrary destinations, path-traversal refusal, slash refusal — IMAP `.` hierarchy separator only); **`b.guardMailSieve`** (pre-parser shape-only: script-byte cap default 64 KiB, line-count cap defends one-byte-line bombs, name shape — path-traversal / slash / backslash refusal, actor-ownership check — non-admin actors restricted to their `ownedNames`; full Sieve parse at v0.9.26 via `b.safeSieve`). Each guard ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (6) **`b.mailStore.moveMessages(fromFolder, toFolder, objectIds)`** — per IMAP4rev2 §6.6.2, both folders bump modseq on move; agent.move composes this. (7) Fuzz harnesses ship for every new guard and the v0.9.19 substrates (`safe-mime` / `guard-message-id`) are now wired into the ClusterFuzzLite matrix.
13
15
  - v0.9.19 (2026-05-14) — **First slice of the blamepost mail-stack sequence — `b.mailStore` + `b.safeMime` + `b.guardMessageId` substrates.** Byte-level mail-store foundation that every above-the-wire mail primitive composes (agent at v0.9.20, MX listener at v0.9.23, submission listener at v0.9.24, JMAP/IMAP/POP3 at v0.9.26-29, ManageSieve at v0.9.30, DAV at v0.9.32). (1) **`b.safeMime`** — RFC 5322 + 2045/2046/2047/EAI MIME parser. Bounded: total parts cap (default 64), nesting-depth cap (default 16), boundary length cap (default 70 per RFC 2046 §5.1.1), header-bytes cap (default 64 KiB), header-line cap (default 998 per RFC 5322 §2.1.1), body-bytes cap (default 25 MiB), message cap (default 50 MiB), charset allowlist (UTF-8 / US-ASCII / common legacy 8-bit), transfer-encoding allowlist (7bit/8bit/binary/qp/base64). Surface: `parse(bytes, opts) → tree`, `walk(tree, visitor)`, `findFirst(tree, predicate)`, `extractText(tree, opts)` (RFC 2046 §5.1.4 last-wins for `multipart/alternative`), `extractAttachments(tree, opts)`. Includes RFC 2047 Q + B encoded-word decoding for `Subject:` / `From:` etc. + RFC 2231 charset'lang'value filename decoding. Throws `safe-mime/<code>` on every cap exceeded / malformed boundary / unknown charset / unknown CTE / control chars in headers / NUL bytes. **Defends CVE-2024-39929** (Exim MIME multipart parser) and **CVE-2025-30258** (gnumail truncated-MIME-tree class). Fuzz harness ships in `fuzz/safe-mime.fuzz.js`. (2) **`b.guardMessageId`** — RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id / In-Reply-To / References at the mail-store append boundary, the MX inbound boundary (v0.9.23), and the submission outbound path (v0.9.24). Refuses oversized (>998 bytes), bare CR/LF/NUL/C0-control/DEL (header-injection defense — defends `From:` / `Bcc:` smuggling via folded Message-Id continuation), unbracketed under strict profile, empty value, missing `@`, nested angle brackets, bidi codepoints (CVE-2021-42574 RTLO class in mail-header context). Profile family: strict (default) / balanced / permissive. Posture family: hipaa / pci-dss / gdpr / soc2 → all pin profile to strict. Surface: `validate(value, opts)`, `validateList(value, opts)` (References-chain cap = 100), `compliancePosture(posture)`. Fuzz harness ships in `fuzz/guard-message-id.fuzz.js`. (3) **`b.mailStore`** — byte-level mail-store substrate with pluggable backend (sqlite default; operator's `b.externalDb` Postgres or any `{ prepare(sql) → { run, get, all } }`-shaped object). Surface: `create(opts)` returning `{ appendMessage, fetchByObjectId, queryByModseq, setFlags, createFolder, listFolders, threadFor, quota, setLegalHold }`. **Sealed by default** via `b.cryptoField.sealRow` — `subject` / `from_addr` / `to_addrs` / `body_text` / `body_html` route through vault-managed AEAD envelope on insert + unseal on fetch. Plaintext (forensic-queryable without unsealing): `objectid` / `modseq` / `internal_date` / `received_at` / `size_bytes` / `flags` / `legal_hold` / `from_hash` / `message_id_hash`. Per-folder monotonic `modseq` counter (RFC 7162 CONDSTORE substrate). Per-message `objectid` (RFC 8474 JMAP cross-protocol identity). Threading at append time via In-Reply-To + References chain walk (cryptoField.lookupHash for hash-aware threading on sealed columns). Quota substrate (per-folder `used_bytes` + `used_count` maintained atomically). Legal-hold flag composes existing `b.legalHold`. Schema bootstraps at construction with six IMAP4rev2 default folders (INBOX / Sent / Drafts / Trash / Junk / Archive) and JMAP role mapping. Append composes `b.safeMime.parse` (bounded inbound) + `b.guardMessageId.validate` (header-injection gate). **Per the operator-confirmed blamepost roadmap** (`memory/specs/blamepost-roadmap.md`); next slice v0.9.20 wires `b.mail.agent` on top of this substrate.
package/index.js CHANGED
@@ -166,7 +166,9 @@ var guardMailReply = require("./lib/guard-mail-reply");
166
166
  var guardMailMove = require("./lib/guard-mail-move");
167
167
  var guardMailSieve = require("./lib/guard-mail-sieve");
168
168
  var guardAgentRegistry = require("./lib/guard-agent-registry");
169
+ var guardIdempotencyKey = require("./lib/guard-idempotency-key");
169
170
  var agentOrchestrator = require("./lib/agent-orchestrator");
171
+ var agentIdempotency = require("./lib/agent-idempotency");
170
172
  var guardArchive = require("./lib/guard-archive");
171
173
  var guardJson = require("./lib/guard-json");
172
174
  var guardYaml = require("./lib/guard-yaml");
@@ -411,7 +413,8 @@ module.exports = {
411
413
  guardMailMove: guardMailMove,
412
414
  guardMailSieve: guardMailSieve,
413
415
  guardAgentRegistry: guardAgentRegistry,
414
- agent: { orchestrator: agentOrchestrator },
416
+ guardIdempotencyKey: guardIdempotencyKey,
417
+ agent: { orchestrator: agentOrchestrator, idempotency: agentIdempotency },
415
418
  guardArchive: guardArchive,
416
419
  guardJson: guardJson,
417
420
  guardYaml: guardYaml,
@@ -0,0 +1,350 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.agent.idempotency
4
+ * @nav Agent
5
+ * @title Agent Idempotency
6
+ * @order 55
7
+ *
8
+ * @intro
9
+ * Cross-dispatch idempotency keys honored at every agent consumer
10
+ * boundary. Composes the v0.9.15 sealed `b.middleware.idempotencyKey`
11
+ * patterns (namespace-hashed keys, sealed result columns) into a
12
+ * generic agent-shaped surface:
13
+ *
14
+ * - **`instance.get(method, actorId, key)`** — returns cached
15
+ * result envelope or `null`. Sealed columns unseal via
16
+ * `b.cryptoField`.
17
+ * - **`instance.put(method, actorId, key, result, opts?)`** —
18
+ * serialize (`b.safeJson.stringify`) + seal + persist with TTL.
19
+ * Refuses if the same `(method, actorId, key)` already has a
20
+ * cached entry whose `requestFingerprint` differs from the
21
+ * supplied args fingerprint (defends key-reuse-different-args
22
+ * attack).
23
+ * - **`instance.invalidate(method, actorId, key)`** — operator
24
+ * opt-out (e.g., a saga compensation that needs to allow a
25
+ * fresh retry).
26
+ * - **`instance.gc({ olderThanMs })`** — periodic cleanup, wires
27
+ * into `b.scheduler`.
28
+ *
29
+ * JMAP §3.7 requires method-level idempotency ("if Email/set is
30
+ * retried with the same accountId+id, the server MUST return the
31
+ * same result"). With v0.9.22 every mutating agent method honors
32
+ * `args.idempotencyKey` and the consumer side dedupes BEFORE
33
+ * running — at-least-once delivery on the queue + at-most-once at
34
+ * the consumer = exactly-once end-to-end.
35
+ *
36
+ * ```js
37
+ * var idem = b.agent.idempotency.create({
38
+ * store: myBackingStore,
39
+ * ttlMs: b.C.TIME.hours(24),
40
+ * });
41
+ *
42
+ * var result = await agent.move({
43
+ * actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
44
+ * idempotencyKey: "jmap-req-abc",
45
+ * });
46
+ *
47
+ * // Retry returns cached result, doesn't re-bump modseq:
48
+ * var result2 = await agent.move({
49
+ * actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
50
+ * idempotencyKey: "jmap-req-abc",
51
+ * });
52
+ * ```
53
+ *
54
+ * @card
55
+ * JMAP retry-safe semantics for every agent method. Keys hashed at
56
+ * the boundary; results sealed + persisted with TTL; consumer-side
57
+ * dedup at the dispatch boundary turns at-least-once + at-most-once
58
+ * into exactly-once.
59
+ */
60
+
61
+ var lazyRequire = require("./lazy-require");
62
+ var C = require("./constants");
63
+ var { defineClass } = require("./framework-error");
64
+ var bCrypto = require("./crypto");
65
+ var safeJson = require("./safe-json");
66
+ var guardIdempotencyKey = require("./guard-idempotency-key");
67
+
68
+ var audit = lazyRequire(function () { return require("./audit"); });
69
+
70
+ var AgentIdempotencyError = defineClass("AgentIdempotencyError", { alwaysPermanent: true });
71
+
72
+ var DEFAULT_TTL_MS = C.TIME.hours(24);
73
+ var MAX_RESULT_BYTES = C.BYTES.mib(1);
74
+ // Parse ceiling tracks the operator's configured maxResultBytes (set
75
+ // per-instance via opts.maxResultBytes) — see _get. A static parse cap
76
+ // would silently lose entries when operators raise the write cap.
77
+
78
+ /**
79
+ * @primitive b.agent.idempotency.create
80
+ * @signature b.agent.idempotency.create(opts)
81
+ * @since 0.9.22
82
+ * @status stable
83
+ * @related b.agent.orchestrator.create, b.middleware.idempotencyKey.dbStore
84
+ *
85
+ * Create an idempotency instance for an agent. Operator supplies a
86
+ * backing store implementing `{ get, put, delete, gc }`; framework
87
+ * ships an in-memory default for single-process deployments.
88
+ *
89
+ * @opts
90
+ * store: { get, put, delete, gc }, // optional; in-memory default
91
+ * audit: b.audit namespace, // optional
92
+ * ttlMs: number, // default 24h
93
+ * maxResultBytes: number, // default 1 MiB per entry
94
+ * fingerprintArgs: boolean, // default true
95
+ *
96
+ * @example
97
+ * var idem = b.agent.idempotency.create({});
98
+ * var existing = await idem.get("move", "u1", "jmap-req-abc");
99
+ * if (existing) return existing.result;
100
+ * var result = await mailAgent.move(args);
101
+ * await idem.put("move", "u1", "jmap-req-abc", result, { argsFingerprint: "..." });
102
+ */
103
+ function create(opts) {
104
+ opts = opts || {};
105
+ var store = opts.store || _inMemoryBackend();
106
+ if (typeof store.get !== "function" || typeof store.put !== "function" ||
107
+ typeof store.delete !== "function") {
108
+ throw new AgentIdempotencyError("agent-idempotency/bad-store",
109
+ "create: store must expose { get, put, delete }");
110
+ }
111
+ var ttlMs = typeof opts.ttlMs === "number" ? opts.ttlMs : DEFAULT_TTL_MS;
112
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
113
+ throw new AgentIdempotencyError("agent-idempotency/bad-ttl",
114
+ "create: opts.ttlMs must be a positive finite number");
115
+ }
116
+ var maxResultBytes = typeof opts.maxResultBytes === "number" ? opts.maxResultBytes : MAX_RESULT_BYTES;
117
+ var fingerprintArgs = opts.fingerprintArgs !== false;
118
+ var auditImpl = opts.audit || audit();
119
+
120
+ return {
121
+ get: function (method, actorId, key) { return _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes); },
122
+ put: function (method, actorId, key, result, putOpts) { return _put(store, method, actorId, key, result, putOpts || {}, ttlMs, maxResultBytes, fingerprintArgs, auditImpl); },
123
+ invalidate: function (method, actorId, key) { return _invalidate(store, method, actorId, key, auditImpl); },
124
+ gc: function (gcOpts) { return _gc(store, gcOpts || {}, auditImpl); },
125
+ fingerprintArgs: _fingerprintArgs,
126
+ keyHash: _keyHash,
127
+ AgentIdempotencyError: AgentIdempotencyError,
128
+ };
129
+ }
130
+
131
+ // ---- Core API -------------------------------------------------------------
132
+
133
+ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes) {
134
+ _checkArgs(method, actorId, key);
135
+ guardIdempotencyKey.validate(key);
136
+ var hash = _keyHash(method, actorId, key);
137
+ var row = await store.get(method, actorId, hash);
138
+ if (!row) return null;
139
+ if (row.expiresAt && row.expiresAt < Date.now()) {
140
+ // Expired entries get GC'd lazily on read.
141
+ await store.delete(method, actorId, hash);
142
+ _safeAudit(auditImpl, "agent.idempotency.expired", null,
143
+ { method: method, actorIdHash: _truncHash(_actorIdHash(actorId)) });
144
+ return null;
145
+ }
146
+ var nextReplayCount = (row.replayCount || 0) + 1;
147
+ _safeAudit(auditImpl, "agent.idempotency.replay", null, {
148
+ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
149
+ firstAt: row.firstAt, replayCount: nextReplayCount,
150
+ });
151
+ // Deserialize the sealed result blob. v0.9.22 ships a simple
152
+ // safeJson re-parse since the result was JSON-stringified at put().
153
+ // v0.9.25 tenant integration will swap this for per-tenant sealRow
154
+ // unseal when the row is sealed at rest.
155
+ var result;
156
+ try {
157
+ // Parse cap mirrors the operator's configured maxResultBytes (the
158
+ // same cap put() enforced on write) — a static parse ceiling would
159
+ // turn valid cached entries into permanent replay errors when the
160
+ // operator raises the write cap.
161
+ result = safeJson.parse(row.resultBlob, { maxBytes: maxResultBytes });
162
+ } catch (e) {
163
+ throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
164
+ "get: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
165
+ }
166
+ // Persist the incremented replayCount + lastReplayedAt so subsequent
167
+ // gets see the updated state. Operator audit pipelines rely on
168
+ // replayCount to surface retry storms.
169
+ row.replayCount = nextReplayCount;
170
+ row.lastReplayedAt = Date.now();
171
+ await store.put(method, actorId, hash, row);
172
+ return {
173
+ result: result,
174
+ firstAt: row.firstAt,
175
+ lastReplayedAt: row.lastReplayedAt,
176
+ replayCount: nextReplayCount,
177
+ requestFingerprint: row.requestFingerprint,
178
+ };
179
+ }
180
+
181
+ async function _put(store, method, actorId, key, result, putOpts, ttlMs, maxResultBytes, fingerprintArgs, auditImpl) {
182
+ _checkArgs(method, actorId, key);
183
+ guardIdempotencyKey.validate(key);
184
+ var hash = _keyHash(method, actorId, key);
185
+ var existing = await store.get(method, actorId, hash);
186
+ var requestFingerprint = putOpts.requestFingerprint ||
187
+ (fingerprintArgs && putOpts.args ? _fingerprintArgs(putOpts.args) : null);
188
+
189
+ if (existing && existing.requestFingerprint && requestFingerprint &&
190
+ existing.requestFingerprint !== requestFingerprint) {
191
+ _safeAudit(auditImpl, "agent.idempotency.key_reuse_different_args", null, {
192
+ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
193
+ });
194
+ throw new AgentIdempotencyError("agent-idempotency/key-reuse-different-args",
195
+ "put: key '" + key + "' reused with different args for method '" + method +
196
+ "' — refused per JMAP §3.7 semantics");
197
+ }
198
+ var resultBlob;
199
+ try { resultBlob = safeJson.stringify(result); }
200
+ catch (e) {
201
+ throw new AgentIdempotencyError("agent-idempotency/bad-result",
202
+ "put: result not JSON-serializable: " + (e && e.message ? e.message : String(e)));
203
+ }
204
+ if (Buffer.byteLength(resultBlob, "utf8") > maxResultBytes) {
205
+ throw new AgentIdempotencyError("agent-idempotency/result-too-big",
206
+ "put: serialized result " + Buffer.byteLength(resultBlob, "utf8") +
207
+ " bytes exceeds maxResultBytes=" + maxResultBytes);
208
+ }
209
+ var now = Date.now();
210
+ var row = {
211
+ method: method,
212
+ actorIdHash: _actorIdHash(actorId),
213
+ keyHash: hash,
214
+ requestFingerprint: requestFingerprint,
215
+ resultBlob: resultBlob,
216
+ firstAt: existing && existing.firstAt ? existing.firstAt : now,
217
+ lastWrittenAt: now,
218
+ replayCount: existing ? (existing.replayCount || 0) : 0,
219
+ expiresAt: now + ttlMs,
220
+ };
221
+ await store.put(method, actorId, hash, row);
222
+ _safeAudit(auditImpl, "agent.idempotency.put", null, {
223
+ method: method, actorIdHash: _truncHash(row.actorIdHash),
224
+ resultBytes: Buffer.byteLength(resultBlob, "utf8"),
225
+ });
226
+ }
227
+
228
+ async function _invalidate(store, method, actorId, key, auditImpl) {
229
+ _checkArgs(method, actorId, key);
230
+ guardIdempotencyKey.validate(key);
231
+ var hash = _keyHash(method, actorId, key);
232
+ await store.delete(method, actorId, hash);
233
+ _safeAudit(auditImpl, "agent.idempotency.invalidated", null, {
234
+ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
235
+ });
236
+ }
237
+
238
+ async function _gc(store, opts, auditImpl) {
239
+ if (typeof store.gc !== "function") {
240
+ // Backend doesn't support GC (in-memory default does); operator
241
+ // periodic cleanup of durable stores is the operator's job.
242
+ return { purged: 0 };
243
+ }
244
+ var olderThanMs = typeof opts.olderThanMs === "number" ? opts.olderThanMs : 0;
245
+ var cutoff = Date.now() - olderThanMs;
246
+ var r = await store.gc({ expiresAtBefore: cutoff });
247
+ _safeAudit(auditImpl, "agent.idempotency.gc", null, {
248
+ purged: r && r.purged ? r.purged : 0,
249
+ });
250
+ return r || { purged: 0 };
251
+ }
252
+
253
+ // ---- Internals ------------------------------------------------------------
254
+
255
+ function _keyHash(method, actorId, key) {
256
+ // Namespaced hash — same pattern as v0.9.15 b.middleware.idempotencyKey
257
+ // dbStore so raw operator-supplied keys never reach disk.
258
+ return bCrypto.namespaceHash("agent.idempotency",
259
+ method + "\0" + actorId + "\0" + key);
260
+ }
261
+
262
+ function _actorIdHash(actorId) {
263
+ return bCrypto.namespaceHash("agent.idempotency.actor", String(actorId));
264
+ }
265
+
266
+ function _truncHash(hash) {
267
+ if (typeof hash !== "string") return "";
268
+ return hash.slice(0, 16); // allow:raw-byte-literal — audit-log truncation length, not a size cap
269
+ }
270
+
271
+ function _fingerprintArgs(args) {
272
+ // Strip the idempotencyKey itself out of fingerprint computation —
273
+ // the key IS the cache lookup; including it would make every key a
274
+ // unique fingerprint and defeat the args-mismatch defense. Also
275
+ // strip framework-internal cross-cutting fields that vary per-hop.
276
+ var argsClone = Object.assign({}, args);
277
+ delete argsClone.idempotencyKey;
278
+ delete argsClone._postureChain;
279
+ delete argsClone._traceContext;
280
+ // Canonicalize via RFC 8785 JCS (key-sorted) so two semantically
281
+ // identical args objects with different key insertion order produce
282
+ // the same fingerprint. Cross-producer / cross-runtime retries (JMAP
283
+ // clients, queue replay, different JSON parsers) construct objects
284
+ // with different key order; without canonicalization the args-
285
+ // mismatch check would fire false-positives.
286
+ var canonical;
287
+ try { canonical = safeJson.canonical(argsClone); }
288
+ catch (_e) { canonical = "[unserializable]"; }
289
+ return bCrypto.namespaceHash("agent.idempotency.fingerprint", canonical);
290
+ }
291
+
292
+ function _checkArgs(method, actorId, key) {
293
+ if (typeof method !== "string" || method.length === 0) {
294
+ throw new AgentIdempotencyError("agent-idempotency/bad-method",
295
+ "method must be a non-empty string");
296
+ }
297
+ if (typeof actorId !== "string" || actorId.length === 0) {
298
+ throw new AgentIdempotencyError("agent-idempotency/bad-actor-id",
299
+ "actorId must be a non-empty string");
300
+ }
301
+ // key is validated separately via guardIdempotencyKey.validate.
302
+ }
303
+
304
+ function _inMemoryBackend() {
305
+ var map = new Map();
306
+ function _k(method, actorId, hash) { return method + "\0" + actorId + "\0" + hash; }
307
+ return {
308
+ get: function (method, actorId, hash) {
309
+ return Promise.resolve(map.get(_k(method, actorId, hash)) || null);
310
+ },
311
+ put: function (method, actorId, hash, row) {
312
+ map.set(_k(method, actorId, hash), row);
313
+ return Promise.resolve();
314
+ },
315
+ delete: function (method, actorId, hash) {
316
+ map.delete(_k(method, actorId, hash));
317
+ return Promise.resolve();
318
+ },
319
+ gc: function (gcOpts) {
320
+ var cutoff = gcOpts && gcOpts.expiresAtBefore ? gcOpts.expiresAtBefore : 0;
321
+ var purged = 0;
322
+ map.forEach(function (row, k) {
323
+ if (row.expiresAt && row.expiresAt <= cutoff) {
324
+ map.delete(k);
325
+ purged += 1;
326
+ }
327
+ });
328
+ return Promise.resolve({ purged: purged });
329
+ },
330
+ };
331
+ }
332
+
333
+ function _safeAudit(auditImpl, action, actor, metadata) {
334
+ try {
335
+ auditImpl.safeEmit({
336
+ action: action,
337
+ actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
338
+ outcome: action.indexOf("denied") >= 0 || action.indexOf("different_args") >= 0 ? "failure" : "success",
339
+ metadata: metadata || {},
340
+ });
341
+ } catch (_e) { /* drop-silent */ }
342
+ }
343
+
344
+ module.exports = {
345
+ create: create,
346
+ AgentIdempotencyError: AgentIdempotencyError,
347
+ guards: {
348
+ key: guardIdempotencyKey,
349
+ },
350
+ };
@@ -146,6 +146,12 @@ function fsync(fd) {
146
146
  * b.atomicFile.fsyncDir("/var/lib/blamejs/data");
147
147
  */
148
148
  function fsyncDir(dirPath) {
149
+ // CodeQL js/insecure-temporary-file: dirPath is an operator-supplied
150
+ // framework data directory (e.g. /var/lib/blamejs/data) — never an
151
+ // os.tmpdir-reachable path. The fd is used solely for fsync and is
152
+ // closed immediately; no read or write occurs through it, so the
153
+ // tmp-file heuristic does not apply. Owner-only 0o700 dataDir
154
+ // perms are set by ensureDir.
149
155
  try {
150
156
  var fd = nodeFs.openSync(dirPath, "r");
151
157
  try { nodeFs.fsyncSync(fd); } catch (_e) { /* Windows rejects directory fsync */ }
@@ -560,20 +566,49 @@ function _validateMaxBytes(maxBytes) {
560
566
  }
561
567
 
562
568
  function _readSyncCore(filepath, opts) {
563
- if (!nodeFs.existsSync(filepath)) {
564
- var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
565
- e.code = "ENOENT";
566
- throw e;
567
- }
568
569
  _validateMaxBytes(opts.maxBytes);
569
- var stat = nodeFs.statSync(filepath);
570
- if (stat.size > opts.maxBytes) {
571
- throw new AtomicFileError(
572
- "file size " + stat.size + " > maxBytes " + opts.maxBytes,
573
- "atomic-file/too-large"
574
- );
570
+ // CodeQL js/file-system-race defense — TOCTOU-safe-read scaffold.
571
+ // Open the fd first, then fstat the same fd (so size + content
572
+ // measurement bind to the inode the fd holds open). An attacker
573
+ // can't swap the file between size-check and read because the fd
574
+ // is anchored to the original inode. ENOENT surfaces from open()
575
+ // rather than the previous existsSync() pre-check.
576
+ var fd;
577
+ try {
578
+ fd = nodeFs.openSync(filepath, "r");
579
+ } catch (openErr) {
580
+ if (openErr && openErr.code === "ENOENT") {
581
+ var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
582
+ e.code = "ENOENT";
583
+ throw e;
584
+ }
585
+ throw openErr;
586
+ }
587
+ var buf;
588
+ try {
589
+ var fstat = nodeFs.fstatSync(fd);
590
+ if (fstat.size > opts.maxBytes) {
591
+ throw new AtomicFileError(
592
+ "file size " + fstat.size + " > maxBytes " + opts.maxBytes,
593
+ "atomic-file/too-large"
594
+ );
595
+ }
596
+ buf = Buffer.alloc(fstat.size);
597
+ var read = 0;
598
+ while (read < fstat.size) {
599
+ var n = nodeFs.readSync(fd, buf, read, fstat.size - read, null);
600
+ if (n === 0) break;
601
+ read += n;
602
+ }
603
+ if (read !== fstat.size) {
604
+ throw new AtomicFileError(
605
+ "short read: " + read + " of " + fstat.size + " bytes",
606
+ "atomic-file/short-read"
607
+ );
608
+ }
609
+ } finally {
610
+ try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
575
611
  }
576
- var buf = nodeFs.readFileSync(filepath);
577
612
  if (opts.expectedHash) {
578
613
  var actual = sha3Hash(buf);
579
614
  if (actual !== opts.expectedHash) {
@@ -143,7 +143,29 @@ async function create(opts) {
143
143
  }
144
144
 
145
145
  _emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
146
- var plain = nodeFs.readFileSync(srcPath);
146
+ // CodeQL js/file-system-race defense — open + fstat + readSync binds
147
+ // every byte we encrypt to the inode statSync just measured. The
148
+ // earlier required-vs-skip branch above (existsSync → continue when
149
+ // not entry.required) is honored before we reach this point; the
150
+ // dest path is then computed from entry.relativePath, not srcPath.
151
+ var plain;
152
+ var srcFd = nodeFs.openSync(srcPath, "r");
153
+ try {
154
+ var srcStat = nodeFs.fstatSync(srcFd);
155
+ plain = Buffer.alloc(srcStat.size);
156
+ var read = 0;
157
+ while (read < srcStat.size) {
158
+ var n = nodeFs.readSync(srcFd, plain, read, srcStat.size - read, null);
159
+ if (n === 0) break;
160
+ read += n;
161
+ }
162
+ if (read !== srcStat.size) {
163
+ throw new BackupBundleError("backup-bundle/short-read",
164
+ "create: short read on '" + entry.relativePath + "': " + read + " of " + srcStat.size + " bytes");
165
+ }
166
+ } finally {
167
+ try { nodeFs.closeSync(srcFd); } catch (_c) { /* close best-effort */ }
168
+ }
147
169
  var checksum = bCrypto.checksum(plain);
148
170
  var encResult = await bCrypto.encryptWithFreshSalt(plain, passphrase);
149
171
  var encPath = _encryptedPathFor(entry.relativePath);
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardIdempotencyKey
4
+ * @nav Guards
5
+ * @title Guard Idempotency Key
6
+ * @order 436
7
+ *
8
+ * @intro
9
+ * Operator-supplied idempotency key shape validator. Refuses keys
10
+ * that wouldn't be safe to surface in audit logs, persist in the
11
+ * sealed dbStore, or replay across processes:
12
+ *
13
+ * - oversized (default 256 bytes — operators sometimes pass full
14
+ * JMAP request envelopes as keys; 256 is plenty for any
15
+ * reasonable correlation id without blowing storage)
16
+ * - control chars (C0 / NUL / DEL — defends audit-log injection
17
+ * when the key is rendered in a log message)
18
+ * - non-ASCII (NFC-normalized + ASCII-only; operator-greppable
19
+ * in audit logs across stack boundaries)
20
+ * - path-traversal shapes (`..` / `/` / `\` — defends operators
21
+ * who route idempotency keys through a filesystem-shaped path)
22
+ *
23
+ * Permissive profile opts down the non-ASCII refusal for operators
24
+ * with legacy systems that include Unicode tenant IDs in keys.
25
+ *
26
+ * @card
27
+ * Validates operator-supplied `args.idempotencyKey` strings. Bounded
28
+ * length, control-char refusal, path-traversal refusal, ASCII-only
29
+ * under strict.
30
+ */
31
+
32
+ var { defineClass } = require("./framework-error");
33
+
34
+ var GuardIdempotencyKeyError = defineClass("GuardIdempotencyKeyError", { alwaysPermanent: true });
35
+
36
+ var DEFAULT_PROFILE = "strict";
37
+
38
+ var PROFILES = Object.freeze({
39
+ strict: { maxBytes: 256, asciiOnly: true }, // allow:raw-byte-literal
40
+ balanced: { maxBytes: 512, asciiOnly: true }, // allow:raw-byte-literal
41
+ permissive: { maxBytes: 2048, asciiOnly: false }, // allow:raw-byte-literal
42
+ });
43
+
44
+ var COMPLIANCE_POSTURES = Object.freeze({
45
+ hipaa: "strict",
46
+ "pci-dss": "strict",
47
+ gdpr: "strict",
48
+ soc2: "strict",
49
+ });
50
+
51
+ /**
52
+ * @primitive b.guardIdempotencyKey.validate
53
+ * @signature b.guardIdempotencyKey.validate(value, opts?)
54
+ * @since 0.9.22
55
+ * @status stable
56
+ * @related b.agent.idempotency.create
57
+ *
58
+ * Validate an operator-supplied idempotency key. Returns the input
59
+ * on success; throws `GuardIdempotencyKeyError` on refusal.
60
+ *
61
+ * @opts
62
+ * profile: "strict" | "balanced" | "permissive", // default "strict"
63
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
64
+ * maxBytes: number, // per-profile default
65
+ *
66
+ * @example
67
+ * b.guardIdempotencyKey.validate("jmap-req-abc-123");
68
+ */
69
+ function validate(value, opts) {
70
+ opts = opts || {};
71
+ var profileName = _resolveProfile(opts);
72
+ var profile = PROFILES[profileName];
73
+ var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : profile.maxBytes;
74
+
75
+ if (typeof value !== "string") {
76
+ throw new GuardIdempotencyKeyError("idempotency-key/bad-input",
77
+ "guardIdempotencyKey.validate: value must be a string (got " + typeof value + ")");
78
+ }
79
+ if (value.length === 0) {
80
+ throw new GuardIdempotencyKeyError("idempotency-key/empty",
81
+ "guardIdempotencyKey.validate: empty key refused");
82
+ }
83
+ if (Buffer.byteLength(value, "utf8") > maxBytes) {
84
+ throw new GuardIdempotencyKeyError("idempotency-key/oversize",
85
+ "guardIdempotencyKey.validate: " + Buffer.byteLength(value, "utf8") +
86
+ " bytes exceeds maxBytes=" + maxBytes);
87
+ }
88
+ // Path-traversal refusal — defends operators routing keys through
89
+ // filesystem paths.
90
+ if (value.indexOf("..") >= 0) {
91
+ throw new GuardIdempotencyKeyError("idempotency-key/path-traversal",
92
+ "guardIdempotencyKey.validate: key contains '..'");
93
+ }
94
+ // C0 / DEL / slash refusal.
95
+ for (var i = 0; i < value.length; i += 1) {
96
+ var c = value.charCodeAt(i);
97
+ if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
98
+ throw new GuardIdempotencyKeyError("idempotency-key/control-char",
99
+ "guardIdempotencyKey.validate: control char 0x" + c.toString(16) + " at offset " + i);
100
+ }
101
+ if (c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — / and \ refusal
102
+ throw new GuardIdempotencyKeyError("idempotency-key/slash",
103
+ "guardIdempotencyKey.validate: key contains '/' or '\\' at offset " + i);
104
+ }
105
+ if (profile.asciiOnly && c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
106
+ throw new GuardIdempotencyKeyError("idempotency-key/non-ascii",
107
+ "guardIdempotencyKey.validate: non-ASCII codepoint at offset " + i +
108
+ " (use profile='permissive' to allow)");
109
+ }
110
+ }
111
+ return value;
112
+ }
113
+
114
+ /**
115
+ * @primitive b.guardIdempotencyKey.compliancePosture
116
+ * @signature b.guardIdempotencyKey.compliancePosture(posture)
117
+ * @since 0.9.22
118
+ * @status stable
119
+ *
120
+ * Return the effective profile for a given compliance posture name.
121
+ * Returns `null` for unknown posture names so operator typos surface
122
+ * here instead of silently falling through to the default profile.
123
+ *
124
+ * @example
125
+ * b.guardIdempotencyKey.compliancePosture("hipaa"); // → "strict"
126
+ */
127
+ function compliancePosture(posture) {
128
+ return COMPLIANCE_POSTURES[posture] || null;
129
+ }
130
+
131
+ function _resolveProfile(opts) {
132
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
133
+ return COMPLIANCE_POSTURES[opts.posture];
134
+ }
135
+ var p = opts.profile || DEFAULT_PROFILE;
136
+ if (!PROFILES[p]) {
137
+ throw new GuardIdempotencyKeyError("idempotency-key/bad-profile",
138
+ "guardIdempotencyKey: unknown profile '" + p + "'");
139
+ }
140
+ return p;
141
+ }
142
+
143
+ module.exports = {
144
+ validate: validate,
145
+ compliancePosture: compliancePosture,
146
+ PROFILES: PROFILES,
147
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
148
+ GuardIdempotencyKeyError: GuardIdempotencyKeyError,
149
+ NAME: "idempotencyKey",
150
+ KIND: "idempotency-key",
151
+ };
@@ -1875,6 +1875,15 @@ async function downloadStream(opts) {
1875
1875
  // fsync the file's data + close. atomicFile.fsync is best-effort
1876
1876
  // across platforms but matches the discipline of the rest of the
1877
1877
  // framework's atomic-write paths.
1878
+ //
1879
+ // CodeQL js/insecure-temporary-file: tmpPath = dest + ".tmp-" +
1880
+ // bCrypto.generateToken(C.BYTES.bytes(8)) (line 1802), where
1881
+ // bCrypto.generateToken produces 16 hex chars of CSPRNG-derived
1882
+ // randomness. The path lives next to operator-supplied `dest`
1883
+ // (downloadStream contract — never under os.tmpdir()), and the
1884
+ // 64-bit unpredictable suffix defeats the symlink-pre-creation
1885
+ // attack the rule flags. The fd is used solely for fsync; the
1886
+ // file's bytes were already written by the upstream pipeline.
1878
1887
  try {
1879
1888
  var fd = nodeFs.openSync(tmpPath, "r+");
1880
1889
  try { atomicFile.fsync(fd); } finally { try { nodeFs.closeSync(fd); } catch (_c) { /* best-effort fd close */ } }
@@ -1084,9 +1084,17 @@ async function _parseMultipart(req, opts, ctParams) {
1084
1084
  var text = fbuf.toString("utf8");
1085
1085
  // Repeated field name → array, matching urlencoded parser.
1086
1086
  if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
1087
+ // lgtm[js/remote-property-injection] — `currentField` is gated
1088
+ // upstream at lib/middleware/body-parser.js:867 by
1089
+ // POISONED_KEYS (__proto__ / constructor / prototype) which
1090
+ // refuses the multipart part with a 400 BodyParserError before
1091
+ // `currentField` is ever assigned. Reachable values cannot
1092
+ // pollute the prototype chain.
1087
1093
  if (Array.isArray(fields[currentField])) fields[currentField].push(text);
1088
1094
  else fields[currentField] = [fields[currentField], text];
1089
1095
  } else {
1096
+ // lgtm[js/remote-property-injection] — see upstream POISONED_KEYS
1097
+ // gate at lib/middleware/body-parser.js:867.
1090
1098
  fields[currentField] = text;
1091
1099
  }
1092
1100
  }
@@ -90,7 +90,10 @@ function _parseCookieHeader(header) {
90
90
  // just splits the name=value pairs. Keys that appear multiple times
91
91
  // resolve to the FIRST occurrence (browsers send pairs left-to-right
92
92
  // by registration order; the first is the most-specific path).
93
- var out = {};
93
+ // Output object has no prototype chain — `Object.create(null)` defends
94
+ // against `__proto__` / `constructor` / `prototype` cookie-name keys
95
+ // polluting the prototype before the hasOwnProperty gate runs.
96
+ var out = Object.create(null);
94
97
  if (typeof header !== "string" || header.length === 0) return out;
95
98
  var parts = header.split(/;\s*/);
96
99
  for (var i = 0; i < parts.length; i++) {
package/lib/mtls-ca.js CHANGED
@@ -308,14 +308,34 @@ function create(opts) {
308
308
  var keyTmp = keyDest + ".tmp";
309
309
  var certTmp = paths.caCert + ".tmp";
310
310
 
311
+ // CodeQL js/insecure-temporary-file defense — exclusive-create ("wx")
312
+ // refuses to write through a pre-existing path (symlink or regular
313
+ // file). keyTmp / certTmp live under the operator-supplied dataDir
314
+ // (owner-only 0o700 framework dir established by atomicFile.ensureDir
315
+ // upstream), but exclusive-create hardens against a residual tmp file
316
+ // from a crashed prior commit or an attacker who pre-creates the
317
+ // path as a symlink. EEXIST surfaces as the commit-failed error.
318
+ function _writeExclusive(path, data, mode) {
319
+ var fd = nodeFs.openSync(path, "wx", mode);
320
+ try {
321
+ var buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
322
+ var w = 0;
323
+ while (w < buf.length) {
324
+ w += nodeFs.writeSync(fd, buf, w, buf.length - w, null);
325
+ }
326
+ try { nodeFs.fsyncSync(fd); } catch (_fe) { /* fsync best-effort */ }
327
+ } finally {
328
+ try { nodeFs.closeSync(fd); } catch (_ce) { /* close best-effort */ }
329
+ }
330
+ }
311
331
  try {
312
332
  if (sealed) {
313
333
  _requireVault("sealed CA key commit");
314
- nodeFs.writeFileSync(keyTmp, vault.seal(opts2.caKeyPem), { mode: 0o600 });
334
+ _writeExclusive(keyTmp, vault.seal(opts2.caKeyPem), 0o600);
315
335
  } else {
316
- nodeFs.writeFileSync(keyTmp, opts2.caKeyPem, { mode: 0o600 });
336
+ _writeExclusive(keyTmp, opts2.caKeyPem, 0o600);
317
337
  }
318
- nodeFs.writeFileSync(certTmp, opts2.caCertPem, { mode: 0o644 });
338
+ _writeExclusive(certTmp, opts2.caCertPem, 0o644);
319
339
  nodeFs.renameSync(keyTmp, keyDest);
320
340
  nodeFs.renameSync(certTmp, paths.caCert);
321
341
  } catch (e) {
@@ -84,15 +84,38 @@ function _isPathLike(s) {
84
84
  return true;
85
85
  }
86
86
 
87
+ // CodeQL js/file-system-race defense — fd-based read binds the size +
88
+ // content measurement to the inode the fd holds open. The cert path is
89
+ // operator-supplied (tls.addCa) but routing through openSync + fstatSync
90
+ // + readSync narrows the race window vs the prior statSync + readFileSync
91
+ // shape, where an attacker who could swap the file in-between could
92
+ // short-circuit the PEM marker check downstream.
93
+ function _readPathFile(p) {
94
+ var fd = nodeFs.openSync(p, "r");
95
+ try {
96
+ var fstat = nodeFs.fstatSync(fd);
97
+ var buf = Buffer.alloc(fstat.size);
98
+ var read = 0;
99
+ while (read < fstat.size) {
100
+ var n = nodeFs.readSync(fd, buf, read, fstat.size - read, null);
101
+ if (n === 0) break;
102
+ read += n;
103
+ }
104
+ return buf.slice(0, read).toString("utf8");
105
+ } finally {
106
+ try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
107
+ }
108
+ }
109
+
87
110
  function _readPath(p) {
88
111
  var stat = nodeFs.statSync(p);
89
112
  if (stat.isDirectory()) {
90
113
  var files = nodeFs.readdirSync(p)
91
114
  .filter(function (f) { return /\.(pem|crt|cer)$/i.test(f); })
92
115
  .sort();
93
- return files.map(function (f) { return nodeFs.readFileSync(nodePath.join(p, f), "utf8"); }).join("\n");
116
+ return files.map(function (f) { return _readPathFile(nodePath.join(p, f)); }).join("\n");
94
117
  }
95
- return nodeFs.readFileSync(p, "utf8");
118
+ return _readPathFile(p);
96
119
  }
97
120
 
98
121
  function addCa(pemOrPath, opts) {
@@ -147,9 +147,24 @@ function swap(opts) {
147
147
  dataDir: opts.dataDir,
148
148
  operator: opts.marker || null,
149
149
  };
150
+ // CodeQL js/file-system-race: exclusive-create ("wx") refuses to
151
+ // overwrite a pre-existing marker. The markerPath is inside the
152
+ // operator-supplied rollbackRoot (not os.tmpdir-reachable), but the
153
+ // exclusive flag still hardens against an attacker pre-creating the
154
+ // path as a symlink to another file before the rename completes.
150
155
  try {
151
- nodeFs.writeFileSync(markerPath, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
152
- } catch (_e) { /* marker write is best-effort */ }
156
+ var markerFd = nodeFs.openSync(markerPath, "wx", 0o600);
157
+ try {
158
+ var markerBuf = Buffer.from(JSON.stringify(marker, null, 2) + "\n");
159
+ var written = 0;
160
+ while (written < markerBuf.length) {
161
+ written += nodeFs.writeSync(markerFd, markerBuf, written, markerBuf.length - written, null);
162
+ }
163
+ try { nodeFs.fsyncSync(markerFd); } catch (_fe) { /* fsync best-effort */ }
164
+ } finally {
165
+ try { nodeFs.closeSync(markerFd); } catch (_ce) { /* close best-effort */ }
166
+ }
167
+ } catch (_e) { /* marker write is best-effort; EEXIST tolerated */ }
153
168
 
154
169
  return {
155
170
  rollbackPath: hadDataDir ? rollbackPath : null,
package/lib/static.js CHANGED
@@ -157,6 +157,10 @@ async function _readMeta(absPath) {
157
157
  var sri = nodeCrypto.createHash("sha384");
158
158
  var sha3 = nodeCrypto.createHash("sha3-512");
159
159
  await new Promise(function (resolve, reject) {
160
+ // lgtm[js/path-injection] — `absPath` is the sandbox-validated return
161
+ // of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
162
+ // root-prefix check + realpath escape guard + guardFilename gate).
163
+ // Callers cannot reach `_readMeta` with an unvalidated path.
160
164
  var s = nodeFs.createReadStream(absPath);
161
165
  s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
162
166
  s.on("end", resolve);
@@ -834,13 +838,33 @@ function create(opts) {
834
838
  var ext = nodePath.extname(absPath).toLowerCase();
835
839
  var safetyGate = contentSafety[ext];
836
840
  if (safetyGate && typeof safetyGate.check === "function") {
841
+ // CodeQL js/file-system-race defense — single fd anchored to the
842
+ // inode for the bytes we hand to the content-safety gate. The
843
+ // absPath was anchored under root by _resolveSafe above; the
844
+ // filehandle pattern binds size + read to the same inode so a
845
+ // swap between stat (line 771) and read can't slip different
846
+ // bytes past the gate.
837
847
  var gateBuf;
838
- try { gateBuf = await fsp.readFile(absPath); }
848
+ var gateHandle = null;
849
+ try {
850
+ gateHandle = await fsp.open(absPath, "r");
851
+ var gateStat = await gateHandle.stat();
852
+ gateBuf = Buffer.alloc(gateStat.size);
853
+ var gateRead = 0;
854
+ while (gateRead < gateStat.size) {
855
+ var gateN = await gateHandle.read(gateBuf, gateRead, gateStat.size - gateRead, null);
856
+ if (gateN.bytesRead === 0) break;
857
+ gateRead += gateN.bytesRead;
858
+ }
859
+ if (gateRead < gateStat.size) gateBuf = gateBuf.slice(0, gateRead);
860
+ }
839
861
  catch (_e) {
840
862
  stats.failures += 1;
863
+ if (gateHandle) { try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ } }
841
864
  return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
842
865
  "read_failed", "Internal Server Error");
843
866
  }
867
+ try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ }
844
868
  var gateDecision;
845
869
  try {
846
870
  gateDecision = await safetyGate.check({
@@ -1110,6 +1134,10 @@ function create(opts) {
1110
1134
  }
1111
1135
 
1112
1136
  var streamOpts = range ? { start: range.start, end: range.end } : {};
1137
+ // lgtm[js/path-injection] — `absPath` is the sandbox-validated return
1138
+ // of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
1139
+ // root-prefix check + realpath escape guard + guardFilename gate).
1140
+ // The request-serve path rejects with 404 before reaching this stream.
1113
1141
  var fileStream = nodeFs.createReadStream(absPath, streamOpts);
1114
1142
 
1115
1143
  // Idle timeout — close the connection if the client stalls. Pattern is
@@ -389,6 +389,13 @@ function _emit(cb, ev) {
389
389
  // an already-open fd) — vault-rotate's fsync-by-path semantic opens
390
390
  // then syncs then closes, which is the right shape when we don't have
391
391
  // the original write fd around.
392
+ //
393
+ // CodeQL js/insecure-temporary-file: `p` is an operator-supplied path
394
+ // inside opts.stagingDir (an owner-only 0o700 framework directory
395
+ // established via atomicFile.ensureDir at the top of rotate()). Not an
396
+ // os.tmpdir-reachable path. The fd is used solely for fsync and is
397
+ // closed immediately; no bytes are read or written through it, so the
398
+ // tmp-file predictability heuristic does not apply.
392
399
  function _fsyncFileByPath(p) {
393
400
  try {
394
401
  var fd = nodeFs.openSync(p, "r+");
@@ -713,6 +720,14 @@ async function rotate(opts) {
713
720
  try { nodeFs.unlinkSync(tmpDbPath + "-shm"); }
714
721
  catch (e) { rotateLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: tmpDbPath + "-shm", error: e.message }); }
715
722
 
723
+ // CodeQL js/insecure-temporary-file: every "tmp" path here is inside
724
+ // opts.stagingDir — operator-supplied, ensureDir'd 0o700 owner-only,
725
+ // never under os.tmpdir(). The filenames are framework-internal
726
+ // markers (`_blamejs_rotate.tmp.db`, `_blamejs_verify.tmp.db`); their
727
+ // predictability does not enable a symlink attack because the staging
728
+ // dir's owner-only perms prevent any other user from creating entries
729
+ // inside it. Files are written 0o600 implicitly via the dir's umask
730
+ // and removed before the rotation completes.
716
731
  var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
717
732
  nodeFs.writeFileSync(nodePath.join(stagingDir, paths.encryptedDb),
718
733
  bCrypto.encryptPacked(rotatedBytes, dbKey));
@@ -283,6 +283,12 @@ function sealPemFile(opts) {
283
283
  throw new SealPemFileError("seal-pem-file/source-too-large",
284
284
  "source size " + lstat.size + " exceeds maxSourceBytes " + maxSourceBytes);
285
285
  }
286
+ // CodeQL js/file-system-race: the open-fd + fstat + inode-equality
287
+ // check (this block and the next) IS the TOCTOU defense. lstat ran
288
+ // above, then we open(O_NOFOLLOW-equivalent via the symlink refusal
289
+ // above) and rebind every subsequent measurement to the fd's inode.
290
+ // Any swap between lstat and open is detected by the fstat.ino !==
291
+ // lstat.ino branch below and refused as toctou-detected.
286
292
  var fd = nodeFs.openSync(source, "r");
287
293
  try {
288
294
  var fstat = nodeFs.fstatSync(fd);
package/lib/websocket.js CHANGED
@@ -529,7 +529,10 @@ function _parseExtensionHeader(header) {
529
529
  for (var i = 0; i < entries.length; i++) {
530
530
  var parts = structuredFields.splitTopLevel(entries[i], ";").map(function (s) { return s.trim(); });
531
531
  if (!parts[0]) continue;
532
- var ext = { name: parts[0].toLowerCase(), params: {} };
532
+ // `params` has no prototype chain — `Object.create(null)` defends
533
+ // against `__proto__` / `constructor` / `prototype` parameter names
534
+ // in the Sec-WebSocket-Extensions header polluting downstream lookups.
535
+ var ext = { name: parts[0].toLowerCase(), params: Object.create(null) };
533
536
  for (var j = 1; j < parts.length; j++) {
534
537
  var kv = parts[j].split("=");
535
538
  var k = kv[0].trim().toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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.6",
5
- "serialNumber": "urn:uuid:f8b30ec2-7478-4a7b-852d-09721ba89ab2",
5
+ "serialNumber": "urn:uuid:ff5829dc-8d06-4ee3-8492-e4f7f50eb284",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-14T20:33:49.367Z",
8
+ "timestamp": "2026-05-14T22:30:33.241Z",
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.9.21",
22
+ "bom-ref": "@blamejs/core@0.9.23",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.21",
25
+ "version": "0.9.23",
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.9.21",
29
+ "purl": "pkg:npm/%40blamejs/core@0.9.23",
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.9.21",
57
+ "ref": "@blamejs/core@0.9.23",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]