Docs / Developer / Macro evaluation is a closed-world interpreter, not a second script sandbox
Macro evaluation is a closed-world interpreter, not a second script sandbox
Status: accepted, 2026-08-23 (proposed 2026-07-26)
Context owners: src/core/preset/macros/, src/sandbox/, a future src/core/macros/
Supersedes nothing. Constrains: any implementation of the macro engine
Context
Bunzai needs to answer a question it cannot answer today: will this preset's macros actually work
after it is converted to another platform's format? The current answer is name-level only.src/core/preset/macros/support.ts says so in its own header: a macro name missing from the target
engine is a reliable "this dies", but a name that is present proves nothing about syntax or meaning.{{random::a::b}} picks from a list on SillyTavern and is a numeric range on RoleCall.
Closing that gap means being able to run a macro template against a modeled engine and compare the
result, which is what the macro engine design calls for: a tokenizer, a parser, and a
tree-walking evaluator over a registry of handlers.
That collides with project rule 3, which reads: "Imported scripts stay sealed. Lua, macros, and
regex payloads are data at every import and conversion boundary. Execution is only user-invoked
inside the Lua or regex test bench: Lua runs in wasmoon and regex runs in a terminable worker, both
on the isolated sandbox origin described by ADR-009. Do not add another execution path."
Rule 3 names macros. Read at its widest, it forbids the macro engine the specs already describe. Read
at its narrowest, it governs only the sealed script payloads Bunzai imports from other platforms.
The rule cannot be quietly reinterpreted in a code comment by whoever implements the evaluator first.
It needs a decision.
The distinction this ADR draws
The two things rule 3 currently covers with one sentence are not the same hazard.
| Sealed scripts (Lua, regex) | Macro templates | |
|---|---|---|
| What the payload is | Code in a general-purpose language | Text with {{token}} substitutions |
| Who wrote it | An untrusted third party, imported | Same, but interpreted against a fixed vocabulary |
| Expressive power | Turing-complete (Lua); catastrophic backtracking (regex) | Only what a registered handler does |
| Reachable host surface | Would be arbitrary without isolation | None; there is no handler that touches fs, net, or process |
| Failure mode | Escape, exfiltration, hang | Wrong text, unbounded expansion |
| Existing mitigation | Worker isolation, wasmoon, distinct origin (ADR-009) | Bounded depth, bounded output, no I/O |
A Lua interpreter runs whatever the payload says. A macro evaluator runs only handlers Bunzai
wrote, selected by name from a closed registry; an unrecognized name is emitted as literal text.
Those are different risk classes and giving them the same mitigation is not conservatism, it is a
category error that would put a pure text-substitution pass behind a cross-origin worker for no
security gain.
Decision
- Rule 3 is narrowed, in writing, to sealed script payloads: Lua and regex. Those keep ADR-009
isolation and remain the only execution paths that get it. This ADR is the amendment; the rule
text inAGENTS.mdis updated in the same change that accepts this ADR. - Macro evaluation is permitted as an in-process, closed-world interpreter under
src/core/macros/, subject to every constraint in section "Constraints on the evaluator" below. - Conversion still never evaluates. The macro engine design's edge case 21 stands unchanged
and is promoted to an accepted decision: a codec that calls the evaluator during
parse or serialize violates the Round-Trip Law. Macro text is opaque payload to every adapter.
This ADR does not widen what codecs may do; it only permits a separate analysis surface. - The evaluator is reachable from exactly five callers and no others without a new ADR: the
macro test bench (user-invoked), the transfer compatibility report, any future prompt assembly or
Test Stage work, chat display (amended 2026-09-26, below), and the bounded extensionmacros.preview
operation defined by accepted ADR-015. The extension operation requires an explicitly invoked
manifest-declared command and fresh host confirmation for every request; it supplies a strict
value-only context, forces read-only execution and disables author regex, then evaluates once.
Event, prompt-hook, background, scheduled, import, save, and codec paths remain excluded. - Macro handlers are data-only. No handler may perform filesystem, network, process, timer, or
dynamic-code operations.eval,new Function, and dynamicimportof payload-derived paths are
prohibited in the macro tree. This is enforced by a test over the handler registry, not by review
alone.
Constraints on the evaluator
These are acceptance criteria, not aspirations. An implementation missing any of them does not
satisfy this ADR.
- Bounded recursion.
MAX_EVAL_DEPTHof 100, per the spec. A runaway subtree returns empty
rather than throwing or hanging. - Bounded output. A hard ceiling on total expanded characters per run, enforced during
evaluation, not checked afterward. Expansion bombs are the realistic denial vector for a
substitution engine and depth alone does not stop them. - Bounded steps. A node-evaluation budget per run, so a wide template cannot substitute for a
deep one. - No ambient clock or randomness by default. Volatile macros read
context.randomSeedand a
caller-supplied clock. A test that cannot pin them is a test that cannot characterize them. readOnlyhonored. Side-effect macros become no-ops, per the spec.- Unknown names remain literal text and never throw or abort evaluation. They may also be named
in returned diagnostics so callers can distinguish unresolved text from a clean render. - No host surface on
MacroContext. It carries values only. If a future feature wants a handler
that reaches outside, that handler needs its own ADR.
Consequences
- The macro program in the plan becomes schedulable rather than blocked on an unresolved rule.
src/sandbox/stays exactly as it is. This ADR adds no capability there and removes none.- The security posture that actually matters is unchanged: nothing imported from another platform
gets executed as code, at import or at conversion, ever. - A reviewer gains a specific list to check an implementation against, instead of arguing about
whether rule 3 applies. - If this ADR is rejected, the consequence is concrete and should be stated plainly: Bunzai cannot
ever prove macro behavior, only compare macro names, and the transfer report stays permanently at
the name-level honesty it has today.
Amendment, 2026-09-26: chat display
The chat shows messages the way RoleCall does (useDisplayMessages): every message, the greeting
included, runs through the full evaluator each time it is displayed, with the chat's cast, messages,
and variables, so every macro the engine knows resolves in the bubble. One difference from RoleCall is decided
elsewhere and wins: RoleCall reads the live card, but a Bunzai chat keeps its own frozen copy of its
cast (ChatStore rule 10), so display resolves against that copy. Two RoleCall
behaviors are kept on purpose and stay inside this ADR's constraints:
- Volatile macros re-roll. RoleCall's display passes no seed, so
{{random}},{{pick}}, and{{roll}}can change when the list is shown again. Here the display caller supplies a freshrandomSeedper showing, and each message holding a macro takes the next seed: the same behavior, with the
randomness still caller-supplied, never ambient. - Variable writes do not persist from display. The evaluator runs on a working copy of the
chat's variables withreadOnlyoff, so{{setvar}}and the other variable writes behave inside
the text, and the copy is dropped afterwards. RoleCall does the same in effect: it loads the chat's variables
from the server, display writes only its in-memory copy, and prompt assembly is what saves them.
Chats have no variables yet; they arrive with prompt assembly, and until then display runs on an
empty working copy.
Display never writes the evaluated text back to the stored message: the stored message stays the
authored text, as in RoleCall.
What this ADR does not decide
- Whether Risu's
[[name]]CBS dialect or Agnai's named-slot templates get modeled at all. That is a
scope question, not a safety one, and it is blocked on source availability rather than on this
boundary. - Whether the evaluator is ever exposed to the model as a tool. A user-invoked test bench and a
model-callable evaluation tool are different exposure decisions; only the former is contemplated
here.