Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# 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
Expand Down
48 changes: 46 additions & 2 deletions docs/contracts/extension-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,59 @@ 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
global environment is provided.
- 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.

Expand Down
29 changes: 29 additions & 0 deletions lua/cdin/runtime/application.lua
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ 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
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,
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.
Expand Down
56 changes: 56 additions & 0 deletions lua/cdin/runtime/extension/manager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,62 @@ 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 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
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
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
Expand Down
24 changes: 24 additions & 0 deletions lua/cdin/runtime/extension/sandbox.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -54,6 +70,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 not valid_module_name(module_name) 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
Expand Down
89 changes: 89 additions & 0 deletions tests/integration/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,95 @@ 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()
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")
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)
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"))

-- 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({
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))
Expand Down
Loading