From 5f041d5f8bd6c99918d1b600e9b3195f514a2053 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:48:15 +0330 Subject: [PATCH 01/13] feat(runtime): load extensions through scoped module resolver --- lua/cdin/runtime/extension/manager.lua | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index f44de84..d1fb3e4 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -157,6 +157,50 @@ function Manager:load(name, source, config, options) return record end +-- Load the entry module through the Backend-provided, extension-scoped +-- resolver. The resolver must enforce source-root confinement and bounded +-- reads; Runtime never resolves paths or touches the filesystem itself. +function Manager:load_resolved(name, config, options) + options = options or {} + if self.closed then + errors.raise("SHUTDOWN", "plugin manager is closed") + end + if type(name) ~= "string" or name == "" then + errors.raise("INVALID_ARGUMENT", "plugin name is required") + end + if self.plugins[name] then + return self.plugins[name] + end + if type(self.module_resolver) ~= "function" then + errors.raise("UNSUPPORTED", "extension loading requires an approved module resolver", { + capability = "extension_module_resolver", + }) + end + + local ok, source, chunk_name = pcall(self.module_resolver, name, "init") + if not ok then + local failure = errors.normalize(source, "IO_ERROR", "resolve extension:" .. name) + error(failure, 0) + end + if type(source) ~= "string" then + errors.raise("NOT_FOUND", "extension entry module was not found", { + extension = name, + module = "init", + }) + end + if chunk_name ~= nil and (type(chunk_name) ~= "string" or chunk_name == "") then + errors.raise("BAD_EXTENSION", "extension resolver returned an invalid chunk name", { + extension = name, + }) + end + + local load_options = {} + for key, value in pairs(options) do load_options[key] = value end + load_options.source = "resolver" + load_options.chunk_name = chunk_name or ("@" .. name .. "/init.lua") + return self:load(name, source, config, load_options) +end + function Manager:unload(name) local record = self.plugins[name] if not record then From 5b38f9536069f83556fe964576bacbec16633176 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:48:25 +0330 Subject: [PATCH 02/13] feat(runtime): expose trusted extension lifecycle API --- lua/cdin/runtime/application.lua | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index ce495b7..bacc530 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -165,6 +165,32 @@ function Runtime:save_document(id, path, options) return result end +-- Extension lifecycle is a trusted embedding API, not a Frontend +-- Protocol command. A caller must already have local application authority; +-- remote Frontend sessions cannot use this method to load local code. +function Runtime:load_extension(name, config, options) + self:_assert_running() + if config ~= nil and type(config) ~= "table" then + errors.raise("INVALID_ARGUMENT", "extension config must be a table") + end + local record = self.plugins:load_resolved(name, config or {}, options) + return { + name = record.name, + state = record.state, + source = record.source, + } +end + +function Runtime:unload_extension(name) + self:_assert_running() + return self.plugins:unload(name) +end + +function Runtime:list_extensions() + self:_assert_running() + return self.plugins:list() +end + function Runtime:_close_document(id, publish) self:_assert_running() self.documents:get(id) -- Fail before mutating the UI for an unknown identifier. From 78e024b042158607f66566dc65f25e9c35bd99ce Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:48:50 +0330 Subject: [PATCH 03/13] test(runtime): cover resolver-backed extension lifecycle --- tests/integration/run.lua | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index c163e3f..427625a 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -209,6 +209,65 @@ end, unload = function() error("must not run after init failure") end }]] A.error_code(function() runtime.plugins:load("broken", failed_plugin, {}) end, "BAD_EXTENSION") A.falsy(runtime.commands:get("plugin.partial")) +-- Resolver-backed extension lifecycle is available only to trusted Runtime +-- embedders. It is deliberately not registered as a Frontend Protocol command. +A.equal(#runtime:list_extensions(), 0) +A.equal(runtime.commands:get("extension.load"), nil) +A.error_code(function() runtime:load_extension("disabled", {}) end, "UNSUPPORTED") + +local extension_sources = { + init = [[ + local greeting = require("helpers.greeting") + return { + init = function(core, config) + core.commands:register("resolved.greeting", function() + return greeting.value .. (config.suffix or "") + end) + end, + unload = function() end + } + ]], + ["helpers.greeting"] = [[return { value = "hello" }]], +} +local resolver_calls = {} +local resolver_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function(owner, module_name) + resolver_calls[#resolver_calls + 1] = owner .. ":" .. module_name + local source = extension_sources[module_name] + if source == nil then return nil end + return source, "extension://" .. owner .. "/" .. module_name + end, +}) +resolver_runtime:start() +local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "!" }) +A.equal(resolved.name, "sample-resolved") +A.equal(resolved.state, "active") +A.equal(resolved.source, "resolver") +A.equal(resolver_runtime.commands:execute("resolved.greeting", {}, {}), true) +local _, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) +A.equal(resolved_value, "hello!") +A.equal(resolver_calls[1], "sample-resolved:init") +A.equal(resolver_calls[2], "sample-resolved:helpers.greeting") +local listed_extensions = resolver_runtime:list_extensions() +A.equal(#listed_extensions, 1) +A.equal(listed_extensions[1].name, "sample-resolved") +A.truthy(resolver_runtime:unload_extension("sample-resolved")) +A.falsy(resolver_runtime.commands:get("resolved.greeting")) +A.equal(#resolver_runtime:list_extensions(), 0) +A.truthy(resolver_runtime:shutdown()) + +local missing_resolver_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() return nil end, +}) +missing_resolver_runtime:start() +A.error_code(function() + missing_resolver_runtime:load_extension("missing-extension", {}) +end, "NOT_FOUND") +A.equal(#missing_resolver_runtime:list_extensions(), 0) +A.truthy(missing_resolver_runtime:shutdown()) + -- CDIN Runtime runs in its own Lua state; strict globals are installed before -- the rest of the runtime modules load. A.falsy(pcall(function() cdin_runtime_undeclared_global = true end)) From 936be24353694eec3e3a910910fc442dcd757320 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:49:15 +0330 Subject: [PATCH 04/13] fix(runtime): validate extension namespace before resolution --- lua/cdin/runtime/extension/manager.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index d1fb3e4..f56e60d 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -168,6 +168,12 @@ function Manager:load_resolved(name, config, options) if type(name) ~= "string" or name == "" then errors.raise("INVALID_ARGUMENT", "plugin name is required") end + -- Plugin names are namespace identifiers, never filesystem paths. Backend + -- still performs canonical path confinement after mapping this identifier. + if #name > 128 or not name:match("^[%w_][%w_.%-]*$") + or name:find("%.%.") or name:find("%.$") then + errors.raise("INVALID_ARGUMENT", "plugin name is not a valid namespace identifier") + end if self.plugins[name] then return self.plugins[name] end From fb940df2f59c08600d502ee835e771e7e470dc41 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:49:23 +0330 Subject: [PATCH 05/13] fix(runtime): reject path syntax in extension module names --- lua/cdin/runtime/extension/sandbox.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/cdin/runtime/extension/sandbox.lua b/lua/cdin/runtime/extension/sandbox.lua index 6fc8c70..8a564d1 100644 --- a/lua/cdin/runtime/extension/sandbox.lua +++ b/lua/cdin/runtime/extension/sandbox.lua @@ -54,6 +54,14 @@ function Sandbox:_environment(plugin_name) if type(module_name) ~= "string" or module_name == "" then errors.raise("INVALID_ARGUMENT", "require name must be a string") end + -- The Backend resolver maps dotted Lua module names within one extension + -- namespace. Reject path syntax here as defense in depth; the Backend must + -- still enforce canonical filesystem confinement for the resolved file. + if #module_name > 256 or not module_name:match("^[%w_%-]+([.][%w_%-]+)*$") then + errors.raise("PERMISSION_DENIED", "extension module name is outside its namespace", { + module = module_name, + }) + end if self.cache[module_name] ~= nil then return self.cache[module_name] end From 8b24d05bf214b4f346228b4b0351fbaf3033931a Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:49:32 +0330 Subject: [PATCH 06/13] test(runtime): reject extension path traversal --- tests/integration/run.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 427625a..986653c 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -240,6 +240,9 @@ local resolver_runtime = Runtime.new({ end, }) resolver_runtime:start() +A.error_code(function() + resolver_runtime:load_extension("../outside", {}) +end, "INVALID_ARGUMENT") local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "!" }) A.equal(resolved.name, "sample-resolved") A.equal(resolved.state, "active") @@ -255,6 +258,11 @@ A.equal(listed_extensions[1].name, "sample-resolved") A.truthy(resolver_runtime:unload_extension("sample-resolved")) A.falsy(resolver_runtime.commands:get("resolved.greeting")) A.equal(#resolver_runtime:list_extensions(), 0) +local module_path_escape = [[return { init = function() require("../outside") end }]] +A.error_code(function() + resolver_runtime.plugins:load("module-escape", module_path_escape, {}) +end, "BAD_EXTENSION") +A.falsy(resolver_runtime.commands:get("module-escape.command")) A.truthy(resolver_runtime:shutdown()) local missing_resolver_runtime = Runtime.new({ From af8adf1bf077e8c127e1c4eb0871ac6bd0a3237b Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:49:52 +0330 Subject: [PATCH 07/13] docs(runtime): document resolver-backed extension lifecycle --- docs/contracts/extension-api.md | 48 +++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index 7e74869..e1047dd 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -59,6 +59,49 @@ The current manager exposes limited capabilities: The full legacy `system`, `themes`, package path and UI extension API has not been migrated. It must not be assumed available based on the old CDIN tree. +## Resolver-backed lifecycle + +A Runtime embedded by a trusted application may be constructed with the optional +`module_resolver(plugin_name, module_name)` callback. Backend owns this callback +and its filesystem/catalog policy. Runtime never accepts a filesystem path and +never reads extension files itself. + +For each extension, Backend resolves the entry module name `init` inside that +extension's namespace. Nested Lua `require("sub.module")` calls use the same +resolver and the same owning extension name. The resolver returns the exact Lua +source string and may return a virtual chunk name for diagnostics. It must +return `nil` for a module that does not exist and must not expose absolute host +paths in chunk names or error messages. + +Trusted embedders may use the following Runtime methods: + +- `runtime:load_extension(name, config?, options?)` resolves and loads the + extension entry point, then returns only `{ name, state, source }`. +- `runtime:unload_extension(name)` invokes the explicit unload hook and removes + the extension's owned registrations. +- `runtime:list_extensions()` returns sorted metadata records; it does not + expose module tables, sandbox environments or source bytes. + +The extension name is a bounded namespace identifier, not a path. Nested +module names are bounded dotted identifiers; slash, backslash, traversal and +other path syntax are rejected before the resolver is called. Backend must +still canonicalize and confine the resolved file/catalog entry to the +requesting extension's root, reject symlink/reparse-point escape, bound source +bytes, and return only on the Lua owner thread. Runtime-side name validation is +defense in depth, not a substitute for Backend path confinement. + +These methods are trusted embedding APIs, **not Frontend Protocol commands**. +A connected Driver must not gain the ability to load arbitrary local code by +sending `request.command`. An application that exposes extension management +to users must supply its own local administrative authorization boundary and +must not route it through an ordinary remote editing session. + +When no resolver is supplied, resolver-backed loading fails closed with +`UNSUPPORTED`. Explicit-source loading via the manager remains available to +controlled embedding and deterministic tests; it must not be exposed to +untrusted clients. The Lua sandbox remains defense in depth, not process +isolation. + ## Sandbox rules - No ambient `io`, `os`, `debug`, `package`, `dofile`, native loader or unrestricted @@ -66,8 +109,9 @@ been migrated. It must not be assumed available based on the old CDIN tree. - Standard `math`, `string`, `table` and `utf8` libraries are read-only proxies. - Global reads/writes that are not explicitly approved fail with a structured extension error. -- `require()` is disabled unless the host supplies a module resolver; a resolver - must scope module access to the requesting extension and prevent path escape. +- `require()` is disabled unless the Backend supplies a module resolver; nested + names are dotted identifiers and resolution must remain inside the requesting + extension namespace. Backend must prevent canonical path/reparse-point escape. - Filesystem, process, clock and clipboard functionality is available only via separately documented capabilities when granted by Backend. From 9b9c5ad1c2621d83adcd109b43bed5caa0487d1e Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:50:38 +0330 Subject: [PATCH 08/13] fix(runtime): validate extension lifecycle options --- lua/cdin/runtime/application.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index bacc530..18b7879 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -173,6 +173,9 @@ function Runtime:load_extension(name, config, options) if config ~= nil and type(config) ~= "table" then errors.raise("INVALID_ARGUMENT", "extension config must be a table") end + if options ~= nil and type(options) ~= "table" then + errors.raise("INVALID_ARGUMENT", "extension options must be a table") + end local record = self.plugins:load_resolved(name, config or {}, options) return { name = record.name, From a631faa0e21eac79be089aac60840d4cf8ae0531 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:50:46 +0330 Subject: [PATCH 09/13] fix(runtime): validate resolver load arguments --- lua/cdin/runtime/extension/manager.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index f56e60d..e1f2ab8 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -162,6 +162,12 @@ end -- reads; Runtime never resolves paths or touches the filesystem itself. function Manager:load_resolved(name, config, options) options = options or {} + if type(options) ~= "table" then + errors.raise("INVALID_ARGUMENT", "extension options must be a table") + end + if config ~= nil and type(config) ~= "table" then + errors.raise("INVALID_ARGUMENT", "extension config must be a table") + end if self.closed then errors.raise("SHUTDOWN", "plugin manager is closed") end From 64e182fafdb3a6b25080e2c20a3c25e2cc3b1179 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:50:55 +0330 Subject: [PATCH 10/13] docs(runtime): record resolver-backed extension lifecycle --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c204ca5..81925f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [Unreleased] + +### Added + +- Resolver-backed extension loading through the trusted Runtime embedding API: + `load_extension`, `unload_extension` and `list_extensions`. +- Entry modules are resolved as `init` within the extension namespace; nested + `require` accepts bounded dotted module identifiers only. +- Resolver-backed lifecycle tests cover module loading, owner scoping, unload + cleanup, unavailable resolver failure and path-traversal rejection. + +### Security + +- Extension lifecycle methods are not exposed as Frontend Protocol commands. + Loading remains disabled when no approved Backend resolver is supplied. +- Runtime rejects path-like extension and nested module names before they reach + the Backend resolver. Backend must still enforce canonical path confinement, + symlink/reparse-point protection and source-size limits. + + ## [1.0.0-rc.1] - 2026-11-04 ### Added From d340127c72bb6425506e7cdaf03c214cdc7f831f Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:57:55 +0330 Subject: [PATCH 11/13] fix(runtime): validate dotted module names with Lua patterns --- lua/cdin/runtime/extension/sandbox.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lua/cdin/runtime/extension/sandbox.lua b/lua/cdin/runtime/extension/sandbox.lua index 8a564d1..da04b9a 100644 --- a/lua/cdin/runtime/extension/sandbox.lua +++ b/lua/cdin/runtime/extension/sandbox.lua @@ -18,6 +18,22 @@ local function readonly(source, label) }) end +local function valid_module_name(module_name) + if type(module_name) ~= "string" or module_name == "" or #module_name > 256 then + return false + end + if module_name:sub(1, 1) == "." or module_name:sub(-1) == "." or + module_name:find("..", 1, true) then + return false + end + for segment in module_name:gmatch("[^.]+") do + if not segment:match("^[%w_%-]+$") then + return false + end + end + return true +end + function Sandbox.new(options) options = options or {} return setmetatable({ @@ -57,7 +73,7 @@ function Sandbox:_environment(plugin_name) -- The Backend resolver maps dotted Lua module names within one extension -- namespace. Reject path syntax here as defense in depth; the Backend must -- still enforce canonical filesystem confinement for the resolved file. - if #module_name > 256 or not module_name:match("^[%w_%-]+([.][%w_%-]+)*$") then + if not valid_module_name(module_name) then errors.raise("PERMISSION_DENIED", "extension module name is outside its namespace", { module = module_name, }) From c1cc8b5834a3a81dd58c2f15aaab8b48ab38b80c Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:58:08 +0330 Subject: [PATCH 12/13] test(runtime): cover malformed dotted extension module names --- tests/integration/run.lua | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 986653c..46ce718 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -263,6 +263,28 @@ A.error_code(function() resolver_runtime.plugins:load("module-escape", module_path_escape, {}) end, "BAD_EXTENSION") A.falsy(resolver_runtime.commands:get("module-escape.command")) + +-- Dotted module identifiers must accept ordinary segments and reject empty +-- segments/path syntax before the resolver is called. +local invalid_module_names = { + ".leading", + "trailing.", + "double..dot", + "helpers/escape", + "helpers\\\\escape", + "../outside", +} +for index, module_name in ipairs(invalid_module_names) do + local plugin_name = "invalid-module-" .. index + local source = "return { init = function() require(" .. string.format("%q", module_name) .. ") end }" + local resolver_calls_before = #resolver_calls + A.error_code(function() + resolver_runtime.plugins:load(plugin_name, source, {}) + end, "BAD_EXTENSION") + A.equal(#resolver_calls, resolver_calls_before) + A.falsy(resolver_runtime.commands:get(plugin_name .. ".command")) +end + A.truthy(resolver_runtime:shutdown()) local missing_resolver_runtime = Runtime.new({ From bb1674d7cc9b940234dfc64dffde3c660c83fb9c Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 11:58:14 +0330 Subject: [PATCH 13/13] docs(runtime): fix changelog spacing --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81925f7..5e82a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ the Backend resolver. Backend must still enforce canonical path confinement, symlink/reparse-point protection and source-size limits. - ## [1.0.0-rc.1] - 2026-11-04 ### Added