Dynamic Evaluation
Execute code dynamically at runtime with sandboxed environments and controlled module access.
Two Systems
Wippy provides two evaluation systems:
| System | Purpose | Use Case |
|---|---|---|
expr |
Expression evaluation | Config, templates, simple calculations |
eval_runner |
Full Lua execution | Plugins, user scripts, dynamic code |
expr Module
Lightweight expression evaluation using the expr-lang syntax.
local expr = require("expr")
local result, err = expr.eval("x + y * 2", {x = 10, y = 5})
-- result = 20
Compiling Expressions
Compile once, run many times:
local program, err = expr.compile("price * quantity")
local total1 = program:run({price = 10, quantity = 5})
local total2 = program:run({price = 20, quantity = 3})
Supported Syntax
-- Arithmetic
expr.eval("1 + 2 * 3") -- 7
expr.eval("10 / 2 - 1") -- 4
expr.eval("10 % 3") -- 1
-- Comparison
expr.eval("x > 5", {x = 10}) -- true
expr.eval("x == y", {x = 1, y = 1}) -- true
-- Boolean
expr.eval("a && b", {a = true, b = false}) -- false
expr.eval("a || b", {a = true, b = false}) -- true
expr.eval("!a", {a = false}) -- true
-- Ternary
expr.eval("x > 0 ? 'positive' : 'negative'", {x = 5})
-- Functions
expr.eval("max(1, 5, 3)") -- 5
expr.eval("min(1, 5, 3)") -- 1
expr.eval("len([1, 2, 3])") -- 3
-- Arrays
expr.eval("[1, 2, 3][0]") -- 1
-- String concatenation
expr.eval("'hello' + ' ' + 'world'")
eval_runner Module
Full Lua execution with security controls.
local runner = require("eval_runner")
local result, err = runner.run({
source = [[
local function double(x)
return x * 2
end
return { double = double }
]],
method = "double",
args = {21}
})
-- result = 42
Configuration
| Parameter | Type | Description |
|---|---|---|
source |
string | Lua source code (required) |
method |
string | Function to call in returned table |
args |
any[] | Arguments passed to function |
modules |
string[] | Allowed builtin modules |
imports |
table | Registry entries to import |
context |
table | Values available as ctx |
allow_classes |
string[] | Additional module classes |
custom_modules |
table | Custom tables as modules |
limits |
table | Execution limits for this run |
Step Limit
limits.max_steps bounds how long one runner.run may execute:
local result, err = runner.run({
source = user_source,
method = "main",
limits = {max_steps = 500}
})
A step is one turn of the eval scheduler: the program advances until it yields or finishes, and each resume consumes one step. Pure computation between yields counts as one step no matter how long it runs, so the limit bounds scheduling turns, not CPU time.
When the count exceeds the limit the run stops and returns errors.INTERNAL with eval exceeded maximum step limit.
max_steps = 0 means unlimited. Omitting limits inherits the host default:
# .wippy.yaml
lua:
eval:
max_steps: 10000 # default budget for runs without limits.max_steps
# 0 = unlimited; a negative value fails boot
limits applies to runner.run only; runner.compile accepts no limits. limits must be a table containing only max_steps, and max_steps must be a non-negative integer — anything else returns errors.INVALID before the program runs.
Module Access
Whitelist allowed modules:
runner.run({
source = [[
local json = require("json")
return json.encode({hello = "world"})
]],
modules = {"json"}
})
Modules not in the list cannot be required.
Registry Imports
Import entries from the registry:
runner.run({
source = [[
local data = ...
local utils = require("utils")
return utils.format(data)
]],
imports = {
utils = "app.lib:utilities"
},
args = {{key = "value"}}
})
Privileged Imports
An import can be granted modules the eval'd code itself cannot see. Use the table form with id and modules:
runner.run({
source = [[
local pricing = require("pricing")
return pricing.quote(...)
]],
modules = {"json"},
imports = {
pricing = { id = "app.lib:pricing", modules = {"funcs"} }
},
})
The pricing library executes in its own scoped environment where funcs is available; the eval'd source cannot require or reach funcs directly. Granting a module to an import requires the caller to hold eval.module permission for that module — capabilities cannot be delegated beyond what the caller itself is allowed.
Custom Modules
Inject custom tables:
runner.run({
source = [[
return sdk.version
]],
custom_modules = {
sdk = {version = "1.0.0", api_key = "xxx"}
}
})
Context Values
Pass data accessible as ctx:
runner.run({
source = [[
return "Hello, " .. ctx.get("user")
]],
context = {user = "Alice"}
})
Compiling Programs
runner.compile validates source and reports its entrypoint and modules without running it:
local program, err = runner.compile([[
local function process(x)
return x * 2
end
return { process = process }
]], "process", {modules = {"json"}})
program:method() -- "process" (string)
program:modules() -- {"json"} (string[])
The options table accepts the same modules and imports fields as runner.run, and the same eval.module and eval.import permission checks apply. The compiled program is informational; execute by calling runner.run with the source and method.
Security Model
Module Classes
Modules are categorized by capability:
| Class | Description | Default |
|---|---|---|
deterministic |
Pure functions | Allowed |
encoding |
Data encoding | Allowed |
time |
Time operations | Allowed |
nondeterministic |
Random, etc. | Allowed |
process |
Spawn, registry | Blocked |
storage |
File, database | Blocked |
network |
HTTP, sockets | Blocked |
Enabling Blocked Classes
runner.run({
source = [[
local http = require("http_client")
return http.get("https://api.example.com")
]],
modules = {"http_client"},
allow_classes = {"network"}
})
Permission Checks
The system checks permissions for:
eval.compile- Before compilationeval.run- Before executioneval.module- For each module in whitelist, and for each module granted to a privileged importeval.import- For each registry importeval.class- For each allowed class
Configure in security policies.
Compile Cache
Compiled programs are cached in an LRU keyed by source, method, modules, and allowed classes — repeated runs of identical code skip recompilation. Imports and context are bound at run time and do not affect the cache key.
# .wippy.yaml
lua:
eval:
cache_size: 256 # entries; 0 or less disables caching (default: 256)
cache_ttl: 0 # expiry; 0 = no expiry (default: 0)
Error Handling
local result, err = runner.run({...})
if err then
if err:kind() == errors.PERMISSION_DENIED then
-- Access denied by security policy
elseif err:kind() == errors.INVALID then
-- Invalid source or configuration
elseif err:kind() == errors.INTERNAL then
-- Execution or compilation error
end
end
Use Cases
Plugin System
local plugins = registry.find({meta = {type = "plugin"}})
for _, plugin in ipairs(plugins) do
local source = plugin:data().source
runner.run({
source = source,
method = "init",
modules = {"json", "time"},
context = {config = app_config}
})
end
Template Evaluation
local template = "Hello, {{name}}! You have {{count}} messages."
local compiled = expr.compile("name")
-- Fast repeated evaluation
for _, user in ipairs(users) do
local greeting = compiled:run({name = user.name})
end
User Scripts
local user_code = request:body()
local result, err = runner.run({
source = user_code,
modules = {"json", "text"}, -- Safe modules only
context = {data = input_data}
})
See Also
- Expression - Expression language reference
- Exec - System command execution
- Security - Security policies