Skip to content

check-lua

Runs custom validation logic written in Lua (5.4). The escape hatch for domain rules the other validators cannot express.

The script defines a global validate(ctx, content) that returns nil when validation passes, or an error message string when it fails.

Attribute Value Default
check-lua path to a .lua script, relative to the project root —
check-lua-pattern regex; every match’s (?P<value>…) group, or whole match whole block
check-lua-timeout whole number of seconds (≥ 1) the script may run 30

check-lua-pattern and check-lua-timeout work only next to check-lua. On a block without it, they stop the run, because they would do nothing there.

The script path must point to a file inside the repository. Paths that escape it — absolute paths outside the project, ../ traversal, or symlinks pointing outward — are rejected.

A leading #! line and a UTF-8 byte order mark are skipped, as the lua interpreter skips them, so a script that runs standalone runs here unchanged.

colors = [
# <block check-lua="scripts/validate_colors.lua">
'red',
'green',
'blue',
# </block>
]

scripts/validate_colors.lua:

function validate(ctx, content)
if content:find("purple") then
return "purple is not an allowed color"
end
return nil
end
  • ctx — a table with:
    • ctx.file — the source file path, relative to the repository root and always separated by /, on every platform and in every run mode. A script may compare or pattern-match it without normalizing first.
    • ctx.line — the line number of the block’s start tag.
    • ctx.attrs — the block’s own attributes, keyed by attribute name, each value exactly as it was written in the tag. A block tagged <block check-lua="…" name="limits" severity="warning"> gives the script ctx.attrs["check-lua"], ctx.attrs["name"] and ctx.attrs["severity"]. Only blockwatch’s own attributes can appear here.
    • ctx.affects — present only when the block also has an affects attribute. A 1-based array with one table per target, holding file, name and content (trimmed). file uses the same format as ctx.file. Targets that don’t resolve are left out.
      • For a block, name is the block’s name and content is its content.
      • For a whole file, name is nil and content is the whole file.
      • For a symbol (file#/path), name is the path with its #, such as #/version. content is the symbol’s value, or its text in the file for a value that holds other values.
  • content — a string holding the trimmed text content of the block, or, when check-lua-pattern is set, a 1-based array of the values the pattern extracted.

Narrowing the input with check-lua-pattern

Section titled “Narrowing the input with check-lua-pattern”

check-lua-pattern extracts values from the block and passes those to the script instead of the whole content. The content in the Lua script becomes a 1-based array to accommodate all the matches.

prices = [
# <block check-lua="scripts/check_prices.lua" check-lua-pattern="\$(?P<value>\d+)">
"Item A: $50",
"Item B: $150",
# </block>
]
function validate(ctx, content)
for _, price in ipairs(content) do -- content is { "50", "150" }
if tonumber(price) >= 100 then
return "price $" .. price .. " is not under $100"
end
end
return nil
end

A script that wants a single value reads content[1]:

// <block check-lua="scripts/check_latest_gpt_nano_model.lua" check-lua-pattern='str = "(?P<value>[^"]+)"'>
const DEFAULT_MODEL_NAME: &str = "gpt-5.4-nano";
// </block>

Unlike same-as-pattern, the regex runs against the entire block rather than line by line, so a pattern may span several lines.

Combining affects with check-lua lets a script inspect the blocks it affects through ctx.affects — with no file IO, so it works in the default sandboxed mode. This keeps two blocks in sync deterministically:

allowed_colors = [
# <block check-lua="scripts/in_sync.lua" affects=":allowed-colors-docs">
'blue',
'green',
'red',
# </block>
]
docs = [
# <block name="allowed-colors-docs">
'blue',
'green',
'red',
# </block>
]

scripts/in_sync.lua:

function validate(ctx, content)
for _, affected in ipairs(ctx.affects) do
if affected.content ~= content then
return "block '" .. affected.name .. "' in " .. affected.file .. " is out of sync"
end
end
return nil
end

For a plain value comparison, same-as does this without a script.

A whole-file affects target reaches the script the same way, which is how a sandboxed script can read a file it could not otherwise open:

function validate(ctx, content)
for _, affected in ipairs(ctx.affects) do
if affected.name == nil and not affected.content:find(content, 1, true) then
return "the value is missing from " .. affected.file
end
end
return nil
end

By default, Lua scripts run sandboxed with only the coroutine, table, string, utf8, and math standard libraries available. The io, os, and package libraries are not loaded, preventing file system access, command execution, and loading of external modules.

Set BLOCKWATCH_LUA_MODE to change the security level:

Terminal window
# Allow IO and OS libraries (memory-safe, but with file/system access)
BLOCKWATCH_LUA_MODE=safe blockwatch
# Allow all libraries including C module loading (unsafe)
BLOCKWATCH_LUA_MODE=unsafe blockwatch
BLOCKWATCH_LUA_MODE Libraries available Security Level
sandboxed (default) coroutine, table, string, utf8, math Most secure - No file/OS access
safe All memory-safe libraries (including io, os, package) Memory-safe - Allows file/OS access
unsafe All Lua standard libraries with no restrictions (including C modules) Unsafe - Full system access

An empty value means sandboxed, the same as an unset variable. Upper and lower case do not matter, so Safe works like safe. Any other value, such as sandbox, stops a run that checks a check-lua block. The error lists the valid values.

If a script uses a library that is not available in the selected mode, the run stops with a Lua error. The block is not reported as a violation — the whole run fails. So a script that needs io or os will fail every sandboxed run, including fork pull requests that changed nothing related to it. Disable check-lua for those runs instead; see A validator that cannot run fails the check.


← Validators · README