Core Plugin Platform — Lote A + Lote S (facade + seams + capability registry + security) - #18
Merged
Arcoders merged 17 commits intoJul 8, 2026
Conversation
…pt calls The regex flagged crypto's own EncryptedRepository.encrypt/decrypt (member calls and typed-parameter definitions), a pre-existing red on the base branch. Tighten it to `(?<![.\w])(?:encrypt|decrypt|decryptStrict)\s*\((?!\s*\w+\s*:)` so it still catches a lenient bare decrypt on a secret but skips member calls and parameter type declarations.
…th seams The definePlugin facade (returns a SatelliteProviderConstructor) plus the four request-path seams, so a community satellite mounts on the request path with zero core edits: - SEAM-3 authorizer chain (AUTHORIZER_CONTRACT_VERSION): fail-closed, wired into TenantGuardMiddleware. The membership-gate signal stays bound to config.authorizeTenantAccess, so a plugin authorizer is additive and never masks a missing IDOR gate. - SEAM-2 tenant middleware registry (TENANT_MIDDLEWARE_CONTRACT_VERSION): wired into installRouterMacros; the core scope middleware runs first. - SEAM-4 request macros: registerTenantRequestMacro, memoized by Symbol, throws on collision, fail-closed when requireTenant. - Capability registry (CAPABILITY_CONTRACT_VERSION): single-provider provide and degradable consume, typed via the augmentable LasagnaCapabilities interface. Foundation: branded names minted only via assertSafeIdentifier, an assertNever boot dispatcher over the section kinds, an independent PLUGIN_API_CONTRACT_VERSION, and a typed E_PLUGIN_* exception hierarchy (no bare Error on the surface). Hardening: E1 scoped eslint (no-explicit-any / no-non-null-assertion) plus a plugin_type_safety spec; E3 guard.plugin_authorizer isthmus guard and a scoped check-no-silent-catch guard; E5 the authorizer wrapped in executeExtension (deadline-to-deny). E2 config plugins.limits: fail-closed count caps (maxAuthorizers / maxMiddleware / maxCapabilities) enforced at boot plus an authorizerDeadlineMs knob, with bounds validation. New ./plugin subpath export. Docs: the plugins guide, extensibility surface rows, the satellite cookbook, and the configuration reference.
…ot guard
Refactor the reporting and satellite-template providers to
`export default definePlugin({...})`, proving the facade on a shipped satellite
and on the template a community author copies. Reporting keeps its ready() cache
invalidation; the template keeps its start() destroy hook.
Widen check-abi-boot-assertion with DEFINE_PLUGIN_RE so a facade provider still
satisfies the ABI-boot guard (reporting is in its PROVIDERS list). The widening
must land in the same commit as the refactor, or the guard fails on this tree.
… + red-team Lote A hardening, tests only: - Fill the fail-closed lifecycle matrix in behavior_define_plugin: a throwing register / start / ready / shutdown hook aborts with a PluginBootException attributed to its own phase (only boot() was covered before). - security_plugin_identifier_fuzz: a 19-entry hostile identifier corpus (key delimiter, PG escape, statement terminator, path traversal, shell metachar, over-length, NFKC homoglyphs) driven through all five branded-name minters, each rejecting; a SAFE control set each accepts; a rejected mint trips guard.tenant_identifier. Pins the "a brand is proof of safety" invariant. - security_malicious_plugin_fail_closed: a hostile definePlugin cannot smuggle a hostile identifier into a section, hijack another plugin's capability key, or backdoor via a throwing authorizer. The header documents the Lote-S controls (read-only DB role, trusted-list proxy) that are NOT enforced here, so the red-team never claims a sandboxing it does not provide.
authorizer() / middleware() / requestMacro() / defineCapability() — the ergonomic way to author a seam entry. Each mints the branded name (so a hostile name throws at authoring time), stamps the `kind` discriminant, and defaults `contractVersion` to the SDK's current surface constant, so an author writes the fields that matter and never hand-builds the raw discriminated object or reaches for a minter by hand. The output is byte-compatible with the raw shape that definePlugin consumes; defineCapability's typed overload keys `api` on LasagnaCapabilities. Wired into the /plugin barrel and the E1 no-any eslint override (plus the plugin_type_safety surface list, so the drift check stays green). builders.ts is app.booted-safe: it imports only the value constants the barrel already re-exports. Docs: plugins.md now leads each seam with its builder and lists them in the export table. Tests: behavior_plugin_builders.
…ene + backcompat pins Closes Lote A of the plugin platform (E1-E9 now complete). E7 is the durability layer: a committed public-API snapshot per entry point plus backwards-compatibility pins. Per-entry golden: - api-extractor.json was single-entry (index only), so the /plugin and /sdk subpath surfaces were invisible to the golden-diff gate. Add api-extractor.plugin.json + api-extractor.sdk.json (each `extends` the base and overrides only the entry .d.ts + report file name) and rewrite scripts/check-api-report.mjs to discover every api-extractor(.<entry>)?.json per package. Lote C's /mixins config will be picked up with no further edits. New goldens: etc/saas-tenancy.plugin.api.md + .sdk.api.md. Surface hygiene (the golden exposed forgotten-exports on the flagship /plugin surface): - Re-export SatelliteProviderContract/Constructor (definePlugin's return type), TenantModelContract/Status/Metadata (the authorizer's tenant param) from /plugin; SatelliteDependency from /sdk. All type-only, additive. - Export PluginPlatformConfig/PluginLimitsConfig (public via config.plugins) from the root + /types barrels so the config type is nameable. - Branded stays intentionally un-exported (opaque, private unique symbol), documented in the barrel and the one benign remaining golden note. Resync the index golden: it was stale on the branch (predating the crypto base and the Lote A commits), so the separate check:api-report CI step was already red. Regenerated to match the committed public surface. Backcompat pins: a frozen v1 definePlugin spec (hard-coded version 1, must keep booting after a future major by the three-way compat gate) and a hand-written raw SatelliteProviderContract provider (the pre-facade route websockets ships, now pinned by a core test not by prose). Coverage: ratchet core .c8rc floors 57/84/71/57 -> 64/85/75/64 off the measured unit baseline now that Lote A is closed. Gates: core unit 1492, typecheck 0 all workspaces, check 42/42, check-api-report 3/3 in sync, lint 0 on changed, test:coverage green.
The in-memory INSERT handler declared `const row`, shadowing the top-level `function row` builder and tripping eslint no-shadow, the sole remaining `npm run lint` error on the branch. Rename it to `insertedRow`; behavior unchanged (8/8 pass). Branch lint is now clean.
The load-bearing net-new primitive for Lote S. A module-level
AsyncLocalStorage (mirroring the `tenancy` facade) that marks WHICH plugin's
code is executing and whether it is trusted, so downstream controls can gate
on "is untrusted plugin code on the stack right now?" without a container
round-trip on the per-query hot path:
- S3 will route the tenant adapter to a read-only connection when
`pluginScope.untrustedActive()` (the Postgres-enforced firewall);
- S5 will deny core-singleton access to untrusted plugins (labeled
in-process friction).
`trusted` is decided once at scope entry; the store is immutable; scopes
nest with normal ALS semantics; fail-closed by default (anything core has
not explicitly entered as untrusted is treated as trusted core code). No
consumers yet — core enters the scope around plugin callbacks in S3/S5.
Module-level singleton on purpose: it can never be `new`-ed into an empty
second instance, so it stays off the stateful-singleton container list.
8 unit tests pin the trust predicate, async-continuation propagation,
nesting/restore, return-value passthrough, and throw-still-clears.
The typed permission vocabulary a plugin uses to DECLARE the sensitive
capabilities it needs, so `configure` can surface them for explicit operator
consent (S1) and a guard can pin manifest↔spec coherence. Declaration is
disclosure, not enforcement — the real containment is layered (an untrusted
plugin is routed read-only regardless of a `db:write` declaration; the
scheduler/data-change seams land in Lote B/C).
- `PluginPermission` discriminated union (never a bare string[]), with a
single-source bijective serializer/parser to the manifest wire form
(`scheduler` / `data_change:users,orders` / `network:external` / `db:write`).
Fail-closed parse: an unrecognized string or a hostile model identifier in a
`data_change:` list yields null (dropped with a warning), so a crafted
manifest can't smuggle one through.
- `permission.scheduler()/dataChange(...models)/networkExternal()/dbWrite()`
builders — the only sanctioned way to populate `definePlugin({ permissions })`;
`dataChange` mints each model through the identifier guard.
- `ModelName` brand + minter for the data-change model list.
- `PluginSpec.permissions` (metadata, not a boot entry) + manifest
`permissions`/`nativeAddons` fields parsed + canonicalized in
`readSatelliteManifest`.
- `/plugin` barrel exports `permission` + `PluginPermission` + `ModelName`;
added `plugin_permissions.ts` to the E1 no-any surface (eslint override +
plugin_type_safety list); regenerated the plugin + sdk goldens.
14 unit tests: builder→wire→parse bijection, exhaustiveness anchor, fail-closed
rejection of malformed/hostile permissions, and manifest parse/canonicalize.
Gates green: core unit 1514, typecheck 0, check 42/42, api-report 3/3, lint 0.
…sent
Closes the S1 consent loop on the permission model from S-1a.
Guard — `scripts/check-plugin-permissions.mjs` (CI-wired via `npm run check`,
now 43 guards; ships `--self-test`): pins that a satellite's DECLARED permissions
stay coherent between `definePlugin({ permissions })` in the provider source and
`package.json#lasagnaSatellite.permissions` (the wire form the operator consents
to). Neither side may declare a permission the other omits; comparison is order-
and model-order-insensitive. A satellite declaring none in either place is
trivially coherent, so it is inert until a satellite opts in, then enforces
against drift. Models on the same 5 providers as check-abi-boot-assertion.
Consent — `configure` now gates publishing a satellite that declares permissions
on explicit operator consent (fail-closed): `--accept-permissions` or
`LASAGNA_ACCEPT_PERMISSIONS=1` grants non-interactively for CI; on a TTY the
concrete capabilities are shown and confirmed; a non-interactive run WITHOUT the
flag REFUSES (skips the satellite, exitCode=1), so a piped install can never
silently accept a plugin's sensitive capabilities. `printSatelliteManifest` shows
the requested permissions, and a pure `describePluginPermissions` renders each
wire string as a concrete human line (unknown strings pass through, forward-compatible).
Tests: +4 (human-line rendering + forward-compat passthrough) and the guard
self-test. Gates green: core unit 1516, check 43/43, lint 0.
A "Declaring permissions" section in the plugins guide: the permission.* builders, the matching manifest wire form, the check-plugin-permissions coherence gate, and the fail-closed install consent flow (--accept-permissions / LASAGNA_ACCEPT_PERMISSIONS for CI). A callout makes the honest boundary explicit — declaration is disclosure, not a sandbox; real containment is the read-only role and the worker network policy. Import-surface table gains the permission builder + PluginPermission/ModelName.
…alth-check Two supply-chain controls. Install gate (configure): a satellite whose manifest declares `nativeAddons` cannot be sandboxed by the worker Permission Model (a native addon evades --permission), so `configure` refuses to wire it unless the operator acknowledges it as fully trusted — `--allow-native` / `LASAGNA_ALLOW_NATIVE_ADDONS=1`, or a TTY confirmation. Fail-closed: a non-interactive install without the flag skips it (exitCode=1), mirroring the S1 permission consent gate. Health check (lasagna:health-check): runs `npm audit` over the dependency tree and flags each installed satellite that ships a native addon or an install lifecycle script (the vector --ignore-scripts blocks). Exits non-zero on a high/critical advisory so it doubles as a CI gate. The parse/threshold logic is the pure `supply_chain_audit` module (parseNpmAudit / countAtOrAbove / hasInstallScripts, 5 unit tests); the command is a thin cross-platform shell around it. Registered in commands.json + index + documented in commands.md. Gates green: core unit 1521, check 43/43, lint 0.
…lone)
The code half of the S3 firewall: when UNTRUSTED plugin code is on the stack
(pluginScope.untrustedActive()) and a read-only role is configured, the tenant
adapter routes the query to a connection cloned from the tenant's own — same
database/schema/search_path — but authenticated as a SELECT-only Postgres role,
so a write is denied by Postgres, not a JS proxy.
- config `plugins.readOnly = { user, password? }` (PluginReadOnlyConfig,
exported from the root + /types barrels; regenerated the index golden).
- `buildReadOnlyConnectionConfig` clones the primary config overriding ONLY the
credentials, preserving the top-level searchPath exactly like the read-replica
clone (knex reads config.searchPath, so the tenant schema carries over).
- `tenant_adapter.modelConstructorClient` registers the read-only variant
synchronously (the primary is already registered, so its config is available)
and routes to it; core code and trusted plugins keep the normal connection.
9 unit tests: the clone (credentials-only, searchPath preserved) + routing
(untrusted → read-only, register-once, core/trusted/no-config → normal). The
end-to-end "Postgres denies the write" proof + the plugin_ro role land in S-3b.
Gates green: core unit 1528, typecheck 0 all workspaces, check 43/43,
api-report 3/3, lint 0.
…ven)
The infra + end-to-end proof for the S3 firewall (S-3a shipped the adapter
routing). Mirrors the existing rowscope-RLS least-privilege pattern:
- ci.yml: a `plugin_ro` login role, NOSUPERUSER NOBYPASSRLS, created with
`ALTER ROLE plugin_ro SET default_transaction_read_only = on`, plus
PLUGIN_RO_DB_USER/PLUGIN_RO_DB_PASSWORD env (mirrors the rls_ci role step).
- fixture `plugin_ro` connection (mirrors rls_probe): authenticates as that
role in CI, falls back to the writable default locally.
- integration red-team `security_plugin_read_only_role.spec.ts`: proves the
role can SELECT but every write (INSERT/UPDATE/DELETE) is DENIED by Postgres
even when GRANTed, because its transactions are read-only. Self-skips when the
probe resolves to a writable role (local default) and — like the RLS proof —
fails LOUD when PLUGIN_RO_DB_USER is set but the role is writable, so the
guarantee can never ship false-green.
Together with the S-3a unit routing proof (untrusted → the cloned read-only
connection), this closes the loop: untrusted plugin code cannot write to the
tenant database. VALIDATION NOTE: the integration proof is CI-only — it needs the
provisioned plugin_ro role; it self-skips green locally (and PG was down locally
this session), so it was not run here. Lint 0, YAML valid.
The buildable half of S4: the Node Permission Model machinery + the R6#2
native-addon boot guard. (Routing scheduler/onDataChange callbacks onto the
sandboxed worker is inherently Lote B/C — those seams don't exist yet.)
- `worker_sandbox.ts`: `parseWorkerSandboxState(argv)` (pure) + `WORKER_SANDBOX_STATE`
read ONCE at module load from process.execArgv (R7#2) + `NODE_SANDBOX_FLAGS`
(the base `--permission`; host-specific fs allow-lists documented in SECURITY.md,
S-6).
- `assertNativeAddonsSandboxable`: a native (.node) addon evades the Permission
Model, so a plugin declaring one aborts the deploy (PluginBootException,
phase 'nativeAddons') when the current process is sandboxed (`--permission`)
WITHOUT `--allow-addons`. Inert in the non-sandboxed API process and for a
plugin with no native addons.
- `definePlugin({ nativeAddons })` spec field, checked in boot() after the ABI
backstops; regenerated the plugin golden.
Scope note: the docker-compose worker `command:` is deliberately NOT changed here
— forcing `--permission` on the e2e worker un-validated risks breaking e2e CI, and
the worker is @adonisjs/queue-owned. The sandbox is opt-in by the operator on their
worker launch; core supplies the flags + the boot guard that fires when they do.
Recommended flags + ops guidance land in SECURITY.md (S-6).
8 unit tests: argv parse, the fail-closed matrix, and the facade booting a
native-addon plugin inertly in the non-sandboxed process. Gates green: core unit
1534, check 43/43, api-report 3/3, typecheck 0, lint 0.
…y trust allowlist Untrusted third-party plugins now hit labeled in-process friction on the sanctioned paths to core singletons, and sensitive capabilities are allowlist-gated. This is friction, not a boundary (a direct import evades it) — the hard wall for an untrusted write stays the S-3 read-only Postgres role. - sdk/plugin_env.ts: TRUSTED_SATELLITES allowlist reader (fail-closed, memoized on the raw env string, drops malformed entries via the identifier guard). - services/plugin_core_access.ts: assertCoreAccessAllowed() — the single throw site; emits guard.plugin_core_access + throws UnauthorizedCoreAccessException (403) when untrusted plugin code is on the stack. resolveTenantRepository funnels through it; new resolve_database.ts is the guarded (evadable) db accessor. - capability_registry.ts: CapabilityProvision gains `sensitive`; register() gates a sensitive provision by provider name and consume() denies a sensitive cap from an untrusted scope, emitting guard.plugin_capability_trust. CAPABILITY_CONTRACT_VERSION 1 -> 2 (a v1 provision warns, not fails). define_plugin threads the plugin name in; defineCapability builder gains `sensitive`. - 2 typed 403 exceptions, 2 Isthmus registry entries (+matrix recipes), /services barrel exports, /plugin api golden regen, E1 no-any surface + eslint override. - Tests: plugin_env, core-access proxy, capability allowlist (provide+consume emit), builder sensitive-threading, malicious_plugin S5 red-team group. Verified via a 4-lens adversarial review (bypass/tests/ceremony/honesty); 3 low findings folded in. Gates green: core unit 1558, tsc 0 all workspaces, check 43/43, api-report 3/3, lint 0, Isthmus audit-coverage 100% (22 registered guards).
…hreat model (closes Lote S) The last slice of Lote S. Adds operator-facing diagnostics, the fourth plugin-surface Isthmus guard, and the published trust-boundary threat model. - guard.plugin_extension_identifier: the branded-name minters (sdk/brands.ts) now validate with the non-emitting isSafeIdentifier predicate and emit their OWN plugin-surface guard on a reject (distinct from the tenant-DDL guard.tenant_identifier), so a hostile plugin identifier reads apart from a bad tenant id. Same accept/reject set. Registry entry + matrix recipe + fuzz-spec assertion updated. - plugin:doctor command + pure PluginDoctorService: diagnoses the deployed platform posture from the discovered manifests + trust/firewall config — Satellite ABI drift (error/warn), native-addon sandbox risk, dead TRUSTED_SATELLITES entries, a missing read-only firewall while untrusted plugins are installed, and a disclosure of every declared (consent-gated) permission. It does NOT introspect specs (that is the check-plugin-permissions CI guard). Full command ceremony (commands.json, index, commands.md); the service is unit-tested per check. - Threat model: docs/guides/security.md gains the five-layer plugin trust-boundary matrix (S1..S5 + the in-process-sandbox non-goal, each verdict real/friction) plus hardening-checklist items; stability.md gets a plugin-platform row (Experimental); .github/SECURITY.md states the sandbox-escape non-goal. - Coverage floors ratcheted lines/statements 64 -> 65. Verified via a 4-lens adversarial review (bypass/tests/ceremony/honesty); 3 low findings folded in (severity assertions for 3 doctor checks, disclose ALL declared permission kinds not a subset, corrected a stale plugin_env comment). Gates green: core unit 1575, tsc 0 all workspaces, check 43/43, api-report 3/3, lint 0, Isthmus audit-coverage 100% (23 registered guards), coverage green at the new floors. Lote S (S-0..S-6) is complete. Merge order stays A -> S -> B -> C.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stabilizes the core into a public plugin platform so community third-party satellites (workflows, realtime, email, permissions, analytics, search) need zero core edits. Two lotes.
Lote A — facade + request-path seams
definePlugin(spec)facade +./pluginbarrel (one import for authors).LasagnaCapabilities).assertNever, typedE_PLUGIN_*exception hierarchy, per-entry api-extractor goldens for/plugin+/sdk, backcompat pins, a no-anyeslint surface.reportingand thesatellite-templaterefactored todefinePlugin;websocketsleft on the rawSatelliteProviderContractroute (pins backward compatibility).Lote S — security / trust boundary (S-0..S-6)
AsyncLocalStoragetrust primitive (pluginScope).PluginPermissionmodel + fail-closed install consent +check-plugin-permissionsguard.lasagna:health-check.CAPABILITY_CONTRACT_VERSION1 → 2).plugin:doctorcommand +guard.plugin_extension_identifier+ the five-layer threat model docs.The trust boundary is honest: S-3 (read-only role) is the one hard boundary; the S-5 in-process friction is evadable by a direct import and is labeled as such; an in-process plugin sandbox escape is a documented non-goal. See
docs/guides/security.md→ Plugin platform trust boundary.Gates
npm run check43/43,check:api-report3/3 in sync, lint 0.Base / merge order
Stacked on
LASAGNA-040726/crypto-satellite, so this PR shows only the 17 plugin-platform commits. Merge order A → S → B → C: Lote B (scheduler +provisionExtension) and Lote C (onDataChange) land in follow-up PRs and are the reason S is a prerequisite — S contains the background/write-triggered plugin code they introduce.