v3.12.1
release: v3.12.1 - guard nested values of protected/owned context keys (#206)
Slothlet v3.12.1 Changelog
Release Date: July 2026
Release Type: Patch
Branch: release/3.12.1
Overview
Version 3.12.1 is a security patch for the owner-locked / write-protected context keys introduced in v3.12.0. scope({ protect, owners }) guarded only the top-level context key: a write to a nested field of a protected key (context.auth.userId = …) slipped through the lock. Nested values of protected / owned keys are now guarded to the same depth. The release also fixes the ./devcheck export pointing at a file npm never shipped (#209) and rolls up routine dependency updates.
🔒 Security & Permission Hardening
Nested values of protected / owned context keys are now guarded (#207)
scope({ protect, owners }) locked only the first layer: context.auth = x threw CONTEXT_KEY_PROTECTED, but context.auth.userId = x — a write to a nested field of a protected key — silently succeeded, so a leaf could forge nested per-request identity it should not be able to touch. The runtime context proxy's get-trap returned the raw nested object, so nested writes never passed through the enforcing set-trap.
The get-trap now returns a recursive protected view for an owner-locked key whose value is a plain object or array. A write to any nested field — assignment, delete, Object.defineProperty, or an array mutator such as push — routes through the same owner check and throws CONTEXT_KEY_PROTECTED, with the error's key carrying the full path (e.g. auth.userId). A named owner may still write nested fields of a key it owns; a different module cannot. Guarding is scoped to keys named in protect / owners only — every other context key is returned raw and stays fully runtime-mutable — and protected views are memoized per store so a value keeps a stable identity across reads. Both runtimes (AsyncLocalStorage and live bindings) are updated.
Depth caveat. Only plain objects and arrays are wrapped (null-prototype objects count as plain). A protected key whose value is a Date, Map, Set, or class instance is returned raw — wrapping it would break its methods (a wrong this receiver) without actually guarding it, since such values mutate through method calls rather than property writes. Reassigning the key itself is still blocked; internal mutation of a non-plain value is not. Keep protected per-request identity as plain data ({ userId, sessionId }) for full-depth protection.
🐛 Bug Fixes
./devcheck export pointed at a file npm never shipped (#209)
The ./devcheck export's default import condition targeted ./devcheck.mjs, but the files whitelist never included that file — so import("@cldmv/slothlet/devcheck") resolved at spec level yet threw ERR_MODULE_NOT_FOUND in every installed copy, and generateBrowserAssets mirrored the dead entry into every generated importmap (a guaranteed-404 URL). devcheck.mjs now ships: it is safe in installed copies by its own guards (the check only acts when a src/ directory exists without dist/, outside CI, and outside node_modules — never true for an npm install), and shipping the real file preserves its dev-environment tripwire in fresh source clones, which a no-op stub would have silenced.
Belt-and-braces, generateImportMap now verifies each resolved target actually exists on disk before emitting it, so a generated importmap can never carry a URL that 404s by construction — any future export drift gets skipped instead of minted into consumer importmaps.
🧪 Tests
- #207 — a nested-forge suite across the mode / runtime matrix: nested assignment /
delete/defineProperty/ array-mutation all throwCONTEXT_KEY_PROTECTEDwith the reported path; nested reads still resolve; a named owner writes / deletes / defines a nested field it owns while a different module is rejected; the value-type policy (plain object + array wrapped, string /null/Dateraw); memoized view identity; and unprotected keys left fully mutable. - #209 — packaging-contract tests: every non-dev target of every flat
exportsentry must be covered by thefileswhitelist, and every URL in a generated importmap must map to a file that exists on disk. - Full coverage gate green across node and browser arms.
📚 Documentation
- NEW: docs/changelog/v3/v3.12.1.md — this changelog.
- docs/CONTEXT-PROPAGATION.md — new Nested values subsection documenting the depth semantics and the plain-object / array wrapping policy.
- README — refreshed What's New.
🔧 Tooling
test:performancenow fails when the benchmark fails. The aggregated benchmark's top-level handler printed a run-killing error but resolved anyway, so a completely broken run (e.g. a staledist/whose imports no longer resolve) exited 0 with no results — making the pre-push perf gate vacuously green. It now mirrors the single-benchmark script: the error is printed and the process exits non-zero.
Dependency updates
esbuild0.28.0 → 0.28.1 (#201)prettier3.9.4 → 3.9.5 (#202)@eslint/css1.3.0 → 1.4.0 (#203)eslint10.6.0 → 10.7.0 (#204)@types/node26.1.0 → 26.1.1 (#205)
The Dependabot bumps updated only the lockfile within the existing caret ranges, so the declared devDependencies floors are re-synced to the lockfile-resolved (tested) versions in the same release — manifest hygiene, no resolved-version change (npm ci installs the identical versions).
Upgrade notes
- Protected / owned context keys are now guarded at depth. If a key is named in
scope({ protect })/{ owners }, writes to its nested fields now throwCONTEXT_KEY_PROTECTEDwhere they previously succeeded silently. This is a security fix — nested writes were never meant to bypass the lock — so prefer leaving it enforced. If a flow legitimately mutated a nested field, either give the writing module ownership (owners) or do not protect the key. Non-plain values (Date/Map/Set/ class instances) are unchanged: only reassigning the key is blocked, not internal mutation of such a value.