MVF-2026-0001: compiling a program ran whatever builtins its macros called

A macro is expanded while the compiler is running, and expansion resolved the names in a macro body against the whole builtin registry.

Identifier MVF-2026-0001
Internal row M26-EVL-012
Severity S1, priority P0
Affected 2.2.0, 2.3.0, 2.4.0, 2.5.0
Not affected 2.0.0, 2.0.1, 2.1.0, and every 1.x release
Fixed in 2.6.0
Reported externally; filed and confirmed 2026-09-24
Published 2026-10-03
CVE / GHSA none assigned
CVSS:3.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CVSS:4.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

Summary#

A macro is expanded while the compiler is running, and expansion resolved the names in a macro body against the whole builtin registry. So compiling a program ran whatever its macros called — writing files, starting processes, reaching the network, reading the host — before the program started, before a password was asked for, and before any of the run time's controls existed.

Compiling is not running, and nothing in the language reference said otherwise. The macro section described a template the compiler expands; it never said expansion could perform input or output.

Impact#

Anyone who compiled a program they did not write had already run it. That covers more than it first appears to:

  • An examiner building a received script to check that it compiles.
  • mutant test with no arguments, which compiles every test program it can find and filters by name only afterwards.
  • Starting a debug session. The program is compiled before the debugger exists, so "load it and stop at the first line" had already expanded it and stopOnEntry did not help.
  • Running this repository's own go test ./... in an untrusted clone, which reaches expansion through a documentation fence.
  • Importing a library, with no call into it at all. One macro environment is shared across every module in the linked graph, so a dependency's macro ran at the importer's compile. This is the case that makes it a crossed trust boundary rather than a surprising behaviour: the code that runs is not the code the operator chose to compile, and the operator never called it.

At compile time none of the run time's controls exist yet, so nothing was asked and nothing was refused.

What was not affected, checked negatively: the language server never expanded macros — nothing under lsp/ reaches the evaluator, the generator, the compiler or the VM — so opening, editing or saving a file ran nothing, and mutant fmt, mutant lint, mutant sema and mutant graph export never evaluate.

Why this is in scope#

SECURITY.md puts "a program doing what its author wrote" out of scope, and says that running an untrusted program is equivalent to running an untrusted executable. That remains true, and this advisory does not contradict it. The promise that broke is a different one: that compiling a program inspects it and does not run it. An examiner who compiles a script to see whether it is well formed has made no decision to execute it, and the importer of a library has made no decision to execute that library's macros at all.

The policy has been amended to say so explicitly, so the boundary this advisory rests on is written down rather than inferred.

Details#

Three separate paths reached the registry, not one. A fix aimed only at the reported root cause would have left two of them open, and the second and third were found by probing for them rather than by reading the report.

  1. The plain name. The evaluator's identifier lookup resolved against a map holding every registered builtin.
  2. The namespaced spelling. Semantic analysis folds an unbound fs.write to the builtin fs_write, so no identifier for the builtin is ever built and a gate on identifiers cannot see it. A second, independent read of the same map answered it.
  3. A name resolved from a string at run time. with_resource takes the name of what it will call as a string and resolves it while the macro body is running, where no rule that reads names in the source can see it. The same hazard had already been denied by name for the browser REPL and was never carried across to macros.

The fix#

Expansion now resolves only the builtins listed as macro-safe: pure computation over values the body already holds. Everything else is refused by name, and the refusal says which of reading a file, reaching the network, running a program, opening a handle, drawing entropy, reading the clock, or writing state the program would later read back it would have done.

The boundary sits where names are resolved rather than where builtins are called, which is what closes all three paths at once: both source-level doors go through a single resolution point, and the string lookup is reachable only from a builtin that is itself only obtainable from that point. The restriction travels on the environment a macro body runs in, and the evaluator wraps rather than marks an environment its caller owns, so no environment outside expansion is ever restricted and the run time keeps every builtin.

A builtin the list does not mention is refused. There is no path that resolves to "allow", so a builtin added later is refused until somebody classifies it, and a test fails until they do.

Seven builtins that touch nothing are refused anyway: aes_encrypt, aes_decrypt, aes_decrypt_bytes, x509_parse, der_parse, jwt_decode and pem_decode decode cryptographic material, and a compile is the wrong place to run a cipher or a certificate parser over bytes the source carries. Parsers of ordinary structure stay available, because writing code from a table the macro carries is one of the reasons to have macros.

Fixture#

The tests that fail on the unfixed code and pass on the fixed code:

  • evaluator/macro_safety_test.go — a macro body that touches the host is refused, under the plain name, the dotted spelling, inside unquote, inside a template hole, through a function the macro defines, and as a value.
  • evaluator/macro_registry_safety_test.go — walks the whole registry in both directions, so the guarantee is checked for every builtin rather than for a sample.
  • builtin/macro_safety_test.go — the drift guard: a newly registered builtin that nobody has classified fails the build's tests.
  • builtin/macro_safety_purity_test.go — walks each allowed builtin's own Go implementation transitively, denying by default, so "pure" is checked against the code rather than asserted in a comment.
  • policy/macro_guard_test.go — forbids every other route from the evaluator into the registry, which is what stops a fourth door being added later.

Both guards were proved load-bearing before being trusted: a scratch file holding five different second doors turned the structural guard red on all five, and making an allowed builtin read a file turned the purity walk red and named the builtin that reached it.

The reproducers are not published. They are three-line programs, and anyone who needs one can write it from the description above; what matters for keeping the fix in place is in the tests.

Workarounds for affected releases#

There is no configuration that turns this off in 2.2.0 through 2.5.0, because expansion is part of compiling. Until you can upgrade:

  • Do not compile, test, or start a debug session on a program you did not write, and treat compiling an untrusted program as running it.
  • The same applies to a program whose dependencies you did not write.
  • Reading and editing are safe. The language server does not expand macros, so opening a file in an editor, formatting it, linting it, or exporting its graph runs nothing.

What changes for programs that worked before#

A macro body that called a builtin outside the macro-safe list is now a compile error naming the builtin and saying why. This is a deliberate incompatibility: such a body was performing input or output at compile time, which is the behaviour being removed.

Credit#

Reported by Pranjal, a BTech student in his fifth semester at the National Forensic Sciences University (NFSU). He asked to be credited publicly, and the name and affiliation are printed at his request.

Originally rated S4 — a surprising behaviour rather than a crossed boundary, on the grounds that a program has the same powers when it is run. That reasoning does not cover the imported-library case, where a dependency's code runs at the importer's compile with no call into it, and it was re-rated S1/P0 on 2026-09-30. The first assessment is recorded here because an advisory that hides its own disagreements is less useful than one that shows them.

References#

Related reading