Skip to content

v3.7.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 20 May 14:06
· 5 commits to master since this release
v3.7.0
1591aba

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.deleteUser returns child wrappers, not data values, so a defaultPolicy: "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.token in 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);          // resume

PermissionManager.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, the readGating row in the configuration options table, the control.* table entries, an upgrade note, and the rewritten "Other slothlet.* Routes Are Gated Too" section covering the built-in lockCaller/bind allows.
  • 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-generated docs/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 .md file (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, every TypedArray/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, the readGating: false opt-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: false short-circuiting the gate, and the control.readGating() runtime toggle in both directions (plus its non-boolean rejection).
  • tests/vitests/suites/permissions/permissions-config-validation.test.vitest.mjspermissions.readGating with a non-boolean throws INVALID_CONFIG.
  • tests/vitests/suites/permissions/permissions-builtin-allow.test.vitest.mjsslothlet.lockCaller/slothlet.bind allowed under defaultPolicy: "deny" with no rules, other slothlet.* routes staying denied, and a more specific user deny rule overriding the built-in allow.
  • The read-gating suite also covers a null module export reading back as bare null (the bug fix above), in eager and lazy modes.

Coverage remains at 100% across statements, branches, functions, and lines.