v3.7.0
release: v3.7.0 (#92)
Slothlet v3.7.0 Changelog
Release Date: May 2026
Release Type: Minor
Branch: release/3.7.0
Overview
Version 3.7.0 adds read-level permission gating — the permission system now checks property reads of data values, not just function calls.
Until now the permission system was call-gated: every inter-module call (self.payments.charge.process(100)) was checked, but reading a non-function value off a module path was not. A module exporting a Buffer, TypedArray, Date, or primitive left that value readable by any other module via self.something.value regardless of deny rules — the check fires at invocation, and a data value has no invocation step.
Read gating closes that gap. When a permissions block is configured, reading a terminal data value off a module API path is enforced against the rule set exactly like a call. It is on by default; set permissions.readGating: false to opt out and keep the previous calls-only behavior.
Compatibility. v3.7.0 has no breaking API changes. Read gating is a correctness fix that closes an unintended gap: a defaultPolicy: "deny" configuration that previously relied on cross-module data values being freely readable contradicted the stated intent of the deny default — reading a Buffer, Date, or primitive off another module's API path silently bypassed the rule set because the call-gated wrapper only fired on invocation. Those reads are now denied unless an allow rule covers the data path; migrate by adding allow rules for the paths you intend to share, or set readGating: false to restore the previous calls-only behavior. The new slothlet.lockCaller/slothlet.bind built-in allows are strictly additive — a more specific user rule still overrides them. Configurations without a permissions block are entirely unaffected — the permission system remains off and no enforcement runs.
🚀 New Features
Read-level gating — data-value reads are permission-checked
The gap. Permission enforcement lived in the unified wrapper's applyTrap — the trap that fires when a wrapped function is invoked. The property-read trap, getTrap, never consulted the PermissionManager. So a module path that resolves to a non-function value was unprotected:
// api/db/secrets.mjs
export const token = Buffer.from(process.env.API_TOKEN, "utf8");// api/untrusted/plugin.mjs — even with { caller: "untrusted.**", target: "db.**", effect: "deny" }
import { self } from "@cldmv/slothlet/runtime";
export function leak() {
return self.db.secrets.token; // ❌ pre-v3.7.0: read succeeds — no call, no check
}The deny rule never engaged: reading token is a property access, not a call.
Now. With a permissions block configured, self.db.secrets.token from untrusted.plugin is checked against the rule set and throws PERMISSION_DENIED. The target path is the leaf segment (db.secrets.token), so rules target a data value the same way they target a callable:
const api = await slothlet({
dir: "./api",
permissions: {
defaultPolicy: "deny",
rules: [
{ caller: "trusted.**", target: "db.secrets.token", effect: "allow" }
]
}
});To gate calls only — the pre-v3.7.0 behavior — opt out with readGating: false:
permissions: { defaultPolicy: "deny", readGating: false, rules: [ /* … */ ] }What is gated — terminal data values: primitives (string/number/boolean/bigint/symbol), null, and built-in objects (Buffer, every TypedArray view, ArrayBuffer, DataView, Map, Set, WeakMap, WeakSet, Date, RegExp, Promise, Error).
What is NOT gated:
- Namespace traversal — walking
self.admin→.manage→.deleteUserreturns child wrappers, not data values, so adefaultPolicy: "deny"configuration needs no allow rule for every intermediate path segment. - Callable functions — a function reference returns a wrapper; the eventual call is gated by the existing call enforcement.
- External user code — reads from outside any module (
api.db.secrets.tokenin application code) have no caller context and are exempt, mirroring call enforcement.
The self-call bypass still applies — a module reading a data value exported from its own source file is always allowed.
Implementation
getTrap in src/lib/handlers/unified-wrapper.mjs gained an enforceReadGate(value) helper, invoked at every site that returns a terminal value — the impl-resolution path and the cached-own-property paths (reads after a value has been stored unwrapped by ___createChildWrapper). The helper short-circuits cheaply when the manager is absent, disabled, or read gating is opted out — before any value classification runs — so the hot read path is unaffected.
The helper classifies the value (primitive or built-in) and leaves plain objects, arrays, and functions alone — those become child UnifiedWrapper proxies, which match none of the terminal-value checks, so namespace traversal is never gated. When a caller wrapper exists in the async context it calls permissionManager.enforceAccess(callerPath, targetPath, …) with targetPath set to the child leaf path, throwing PERMISSION_DENIED on denial. This is the same enforcement contract applyTrap already uses for calls. The waiting-proxy resolver captures the caller during synchronous chain traversal at proxy-creation time — the chain walk (e.g. self.db.secrets.token) runs inside the calling module's body, so the live store still reflects the actual reader — and the resolver reuses the helper, so lazy reads are gated identically to eager reads. The waiting-proxy cache is keyed by ${callerApiPath}::${propChain} rather than propChain alone, so two different modules touching the same unmaterialized path before materialization completes never share one cached snapshot; each reader's chain walk is attributed to its own module.
PermissionManager (src/lib/handlers/permission-manager.mjs) gained a #readGating field, read from config.permissions.readGating in the constructor, plus an isReadGatingEnabled() accessor — kept separate from isEnabled() so call enforcement is unaffected by the flag. normalizePermissions() in src/lib/helpers/config.mjs validates readGating as a boolean (default true) and throws INVALID_CONFIG for any other value.
Runtime toggle — permissions.control.readGating()
Read gating can be switched on or off after instance creation, the same way the master enforcement switch already could:
api.slothlet.permissions.control.readGating(false); // stop gating reads
const gated = api.slothlet.permissions.control.readGatingEnabled; // → false
api.slothlet.permissions.control.readGating(true); // resumePermissionManager.setReadGating(value) backs the toggle — it validates the argument (INVALID_ARGUMENT for a non-boolean) and, unlike enable()/disable(), does not clear the resolved cache: the flag only controls whether property reads consult the rule set, never the allow/deny outcome of an evaluated pair. control.readGating() and the control.readGatingEnabled accessor live on the slothlet.permissions.control.* namespace, so they are deny-by-default for modules (a trusted module needs an explicit allow rule) and callable from external application code — identical to control.enable()/disable().
slothlet.lockCaller and slothlet.bind are allowed by default
Two built-in allow rules are now registered for every instance:
{ caller: "**", target: "slothlet.lockCaller", effect: "allow" }
{ caller: "**", target: "slothlet.bind", effect: "allow" }The caller-identity utilities slothlet.lockCaller and slothlet.bind grant no security-sensitive access — they only pin a callback's caller identity, which strengthens enforcement. Previously a defaultPolicy: "deny" configuration had to allow them explicitly or modules could not use them; that boilerplate is no longer needed. A more specific user rule (e.g. an exact caller/target pair) still overrides the built-in allow if a particular module should be denied.
This joins the existing built-in deny on slothlet.permissions.control.**, so the built-in rule set is now: deny the enforcement-toggle surface, allow the two caller-identity utilities.
🐛 Bug Fixes
A null module export is exposed as null, not {}
Before: a module exporting null (export const x = null;) surfaced on the API as an empty object {}. api.x === null was false, nullish checks and ?? fallback logic silently took the wrong branch, and serialization produced {}. Every other primitive value type — string, number, boolean, bigint, symbol — and undefined were exposed faithfully; only null was altered.
After: a null export reads back as null, consistent with every other exported value. The fix is in the child-wrapper creation path: a bare null is now stored unwrapped (the same handling as undefined) rather than falling through to the object-wrapping path on account of typeof null === "object". Affected both eager and lazy modes; independent of the permission system.
📚 Documentation Updates
docs/PERMISSIONS.md— new "Read-Level Gating" section, thereadGatingrow in the configuration options table, thecontrol.*table entries, an upgrade note, and the rewritten "Otherslothlet.*Routes Are Gated Too" section covering the built-inlockCaller/bindallows.README.md— promoted v3.7.0 to "Latest", trimmed from ~1060 lines / 47 KB to ~480 lines / 27 KB by delegating each feature's deep dive to its existing dedicated doc and restoring a comprehensive documentation index. Six previously-orphaned docs are now linked from the README:CONFIGURATION.md,LIFECYCLE.md,RELOAD.md,VERSIONING.md,PERMISSIONS-CONDITIONS.md, and the auto-generateddocs/generated/API.md. Section order was also adjusted so content (Error Handling, Production & Development) precedes the link index.docs/MODULE-STRUCTURE.md— added a new "Loading Pipeline Overview" section housing the eager/lazy/materialization flow diagram that previously lived inline in the README.- Repo-wide markdown unwrap — every
.mdfile (docs, READMEs, changelogs, instruction files) had hard-wrapped paragraphs joined back into single soft-wrapping lines. GitHub callouts (> [!NOTE],[!TIP],[!IMPORTANT],[!WARNING],[!CAUTION]) and consecutive bold-label declarations (e.g. changelog**Date**:/**Type**:/**Branch**:triples) are preserved on their own lines.
🧪 Test Coverage
api_tests/api_test_permissions/— fixtures extended with a module exporting non-function data values (Buffer, everyTypedArray/ArrayBuffer/Date/Map/Set/WeakMap/WeakSet/RegExp/Promise/Error/primitive) and a separate caller module that reads them cross-file.tests/vitests/suites/permissions/permissions-read-gating.test.vitest.mjs— the default on-by-default behavior, thereadGating: falseopt-out, denied and allowed reads, namespace traversal staying ungated, external reads exempt, self-call bypass, the cached-value read path, primitives materialized behind a wrapper, every built-in type gated, verbose audit events on a denied read,enabled: falseshort-circuiting the gate, and thecontrol.readGating()runtime toggle in both directions (plus its non-boolean rejection).tests/vitests/suites/permissions/permissions-config-validation.test.vitest.mjs—permissions.readGatingwith a non-boolean throwsINVALID_CONFIG.tests/vitests/suites/permissions/permissions-builtin-allow.test.vitest.mjs—slothlet.lockCaller/slothlet.bindallowed underdefaultPolicy: "deny"with no rules, otherslothlet.*routes staying denied, and a more specific user deny rule overriding the built-in allow.- The read-gating suite also covers a
nullmodule export reading back as barenull(the bug fix above), in eager and lazy modes.
Coverage remains at 100% across statements, branches, functions, and lines.