GEP-32
|
Abstract
This GEP proposes a single, named, documented and tested compiler configuration, the restricted profile, for compiling code that the host did not write by hand but has decided to run. Typical examples are an AI agent’s output in an edit-compile-check loop, cells written by an agent in a notebook, generated scripts, and in-house plugin or rules scripts.
The profile combines mechanisms Groovy already has with one new checker:
-
No execution at compile time:
@Grab,@ASTTestand unexpected global AST transformations are off, so compiling a candidate runs nothing. -
Static compilation forced, so every call has a resolved target the checkers can see.
-
A new effect checker,
RestrictedEffectsChecker, a type-checking extension that refuses reflection, dynamic dispatch, metaclass access, class loading, process execution, dynamic evaluation and raw I/O, including their GDK forms ("cmd".execute(),file.text,url.text), unless the host grants them. -
Existing checkers (strict
NullCheckerby default) and a cooperative runaway guard (ThreadInterrupt).
It is delivered as a preset that hosts extend, a refused-snippet test suite that defines what "restricted" means, documentation of the limits, and a row in the threat model.
Motivation
Code nobody wrote by hand is now compiled constantly
AI coding agents generate Groovy, compile it, read the diagnostics and try again. Notebook kernels, MCP servers and agent harnesses compile snippets they received from a model. The person running these tools has chosen to run the code, but has not read every line. Today the question "how do I restrict what this script can do?" gets a scattered answer: SecureASTCustomizer javadoc, system properties in the threat model’s §5a table, and mailing-list posts. Each answer comes with a warning that it is not a sandbox, and none comes with tests.
"We only compiled it" is not a safety argument
@Grab resolves and downloads dependencies, @ASTTest runs code and global AST transformations on the
classpath run during compilation. An agent loop that compiles a hundred candidates should be able to do so
without running any of them. The switches exist (groovy.grape.enable, groovy.asttest.enable,
CompilerConfiguration#setDisabledGlobalASTTransformations). What is missing is one place that turns them
all off and a test that proves it.
SecureASTCustomizer cannot do the job alone
SecureASTCustomizer runs at CANONICALIZATION, before static type checking has inferred any types.
Probes against Groovy 6.0.0, with @CompileStatic forced and Class, Runtime, ProcessBuilder
and System denied as receivers:
| Snippet | Result | Why |
|---|---|---|
|
refused |
literal receiver class |
|
refused |
indirect import check |
|
compiles |
receiver type not yet inferred |
|
compiles |
a GDK method on |
|
runs |
dynamic dispatch survives |
|
refused |
static compilation: no property |
A check that runs during type checking sees the inferred receiver of every call and the resolved target of
every GDK method. That is where the existing checkers in groovy-typecheckers already work, and it is
where the effect rules belong. SecureASTCustomizer stays useful as a cheap early layer for imports.
Static compilation is the enabler, not the control
The threat model is right that @CompileStatic is not a security control. Statically compiled code is
just as privileged. Its value here is visibility: with dispatch resolved at compile time, a checker can
say what a snippet touches. The profile uses static compilation for that reason and makes no other claim
for it.
Design
Layers
| Layer | Mechanism | Status |
|---|---|---|
No compile-time execution |
Grab transform disabled per compilation, Grab annotations refused, |
existing switches, plus a small refusal customizer |
Static compilation |
|
existing |
Effect rules |
|
new |
Import layer |
SecureASTCustomizer |
existing |
Correctness checkers |
strict |
existing |
Runaway guard |
|
existing |
Typed binding (REPL hosts) |
optional extension, specified separately |
see Related work |
RestrictedEffectsChecker
An opt-in type-checking extension in groovy-typecheckers, usable on its own or through the preset.
It reports a static type error when a resolved call, constructor call, property access or method pointer
targets one of these categories:
| Category | Examples (non-exhaustive) |
|---|---|
Reflection and handles |
|
Dynamic dispatch and the MOP |
|
Class loading and evaluation |
|
Process and system |
|
Ambient I/O |
|
Threads |
|
Rules are written against resolved owners and GDK provider classes, not method names alone. The GDK’s
split already follows authority: ResourceGroovyMethods makes authority from a name (File, URL),
ProcessGroovyMethods starts processes, while IOGroovyMethods works on streams the code was already
handed. The last stays allowed by default, so a host can pass in a Reader and the snippet can use
eachLine on it.
Grants
Hosts widen the profile by naming grants, not by deleting rules:
def config = RestrictedProfile.builder()
.grant(RestrictedProfile.Grant.THREADS)
.grant(RestrictedProfile.Grant.FILE_READ) // java.nio.file read operations only
.checkers('NullChecker(strict: true)', 'PurityChecker')
.build() // a CompilerConfiguration
Grants are categories, not paths. Granting a category says "this code may use files". Which files is a
runtime question for the host, which passes in the Path or stream it is willing to share. Path-scoped and
capability-scoped grants are deferred (see Excluded and deferred features).
Deny list or allow list
The default is a deny list: ordinary computation code compiles, and the refused categories are easy to
explain. An allow-list mode (.allowListOnly()) admits only java.lang value types, java.util,
java.time, java.math, java.util.function, java.util.stream and the GDK without its effect providers,
plus anything granted. This is closer to the "safe mode" of the TACIT work for Scala. The same test suite
runs against both modes.
Shared lists with PurityChecker
PurityChecker already classifies I/O, logging, metrics and non-determinism by type prefix. The two
checkers should share those lists from one source so they cannot drift apart. They answer different
questions: purity asks whether a method has an effect, while the profile asks whether the code may reach
that kind of effect at all.
The preset
RestrictedProfile is a small builder returning a configured CompilerConfiguration. Because REPL hosts
(notebook kernels, groovysh, MCP servers) add their own customizers, the profile must be a preset hosts
extend in code, not only a groovyc --configscript file. A config script that applies the default profile
is also shipped, for the command line and for groovyc --check.
The preset sets, in addition to the layers above:
-
groovy.grape.GrabAnnotationTransformationdisabled for its compilations, throughsetDisabledGlobalASTTransformations, together with all other global AST transformations except those the host lists explicitly; -
a refusal, reported as a compile error, of
@Grab,@GrabConfig,@GrabResolverand@GrabExclude, so the snippet’s author learns that dependencies are unavailable, rather than meeting "unable to resolve class" later; -
@ASTTestdisabled.
Why Grape is disabled per compilation
There are three ways to turn Grape off. Only one is scoped to a compilation:
| Mechanism | Scope | Behaviour (6.0.0 probes) |
|---|---|---|
Keep |
the JVM |
|
|
the JVM, read once into a static |
Also turns off Grape for the host’s own code, for example a notebook kernel’s normal mode in the same JVM. |
Disable |
one compilation |
|
The profile cannot control the host’s classpath, and a JVM-wide switch is too broad for hosts that use
Grape themselves. The per-compilation switch is therefore the mechanism. Keeping the engines off the
classpath remains good defence in depth for hosts that run in a dedicated JVM, such as a guarded kernel,
an MCP server or a CI step running groovyc --check, and the documentation recommends it there.
Disabling the transform does not cover a direct runtime call such as Grape.grab(group: …, module: …,
version: …), which still resolves and loads the dependency. groovy.grape.* is therefore in the effect
checker’s class-loading category.
The @GrabConfig(systemProperties) behaviour without an engine looks unintended. It is proposed as a
separate core ticket, GROOVY-12461: the generated initializer should also be skipped when no engine is available.
Per-method scope
By default the profile covers a whole compilation. Some generated code, though, lives inside hand-written
source. A spec-sync tool, for example, writes a generated method body into an existing class (see Spec sync
and @Spec` below). For that case the effect rules can be limited to methods carrying a marker annotation.
`@Spec is the expected marker; a host may name another.
def config = RestrictedProfile.builder()
.scopeToMethodsAnnotatedWith('groovy.transform.Spec')
.build()
In this mode:
-
the preset adds
@CompileStaticto the marked methods only, so the rest of the class keeps its own compilation mode; -
RestrictedEffectsCheckerchecks the marked method’s body, including closures, lambdas and local or anonymous classes declared in it; -
the compile-time layers (Grab,
@ASTTest, global transforms) stay compilation-wide, because they cannot be applied per method; -
calls from a marked method to unmarked methods written by the host are allowed, as calls into trusted library code are. A generated body can therefore reach an effect through a helper the host wrote. That is the host’s choice and is documented. An optional strict setting refuses such calls unless the callee is itself marked or is
@PureunderPurityChecker.
The refused-snippet suite gains per-method variants of its cases, including an unmarked helper that does what the marked method may not.
Probe results (Groovy 6.0.0)
A prototype used a CONVERSION customizer that adds @CompileStatic(extensions = …) to @Spec methods,
with a toy extension that refuses execute:
| Case | Result |
|---|---|
unmarked method in a dynamic class calls |
compiles and runs as before |
marked method makes a dynamic call |
refused by static type checking |
marked method calls |
refused in all three |
marked method calls an unmarked helper that calls |
allowed, as designed |
marked method in a class that is already |
the extension still applies to it |
marked method in a script |
refused as expected |
unfilled marked method (empty body), with a small |
throws |
marked method reads a |
refused: the property is |
The last row is a usability cost rather than a hole. Generated bodies in dynamic classes meet untyped fields. The sync tool sees the diagnostic and can add a cast or a typed local, but declaring field types is the better fix.
Loops and local declarations
@Spec can also mark a smaller hole. With @ExtendedTarget(ExtendedElementType.LOOP) it is accepted on
for, while and do-while loops, and with ElementType.LOCAL_VARIABLE on local declarations. The
@Spec transform receives the loop statement or the declaration as its target node, so an unfilled loop
body can be made to throw just as an unfilled method does. Neither kind is ever written to the class file,
so such specs exist only in source. That is enough for the sync tool, which works on source, but they are
invisible to reflection and to documentation built from compiled classes.
@CompileStatic cannot be applied to a single statement. Restricting a loop therefore means statically
compiling its enclosing method and limiting the effect checker to statements inside @Spec loops. A
prototype did this: execute() inside the loop was refused, and execute() elsewhere in the same method was
allowed. The cost is that the rest of that method must also type-check statically, so a dynamic call
elsewhere in it is now refused. The profile should support loop scope on these terms, and the sync tool
should prefer method-level holes where the surrounding code is dynamic.
The refused-snippet test suite
The suite defines the profile. It has two halves:
-
must refuse: every row of the motivation table, every category above, and known SecureASTCustomizer bypasses (indirect class references, aliases, method pointers,
with/tapdelegation, string-to-class coercion, code relocated into constructors, initializers and synthetic methods); -
must compile: representative data-processing, collection, string, date and math code, so the profile stays usable.
It follows the style of the existing "refuted" test module: each snippet carries the reason it is refused, and a regression in either half fails the build.
Positioning and the threat model
THREAT_MODEL.md gains a §5a row and a §9 pointer. Suggested wording:
Restricted profile (
RestrictedProfile). For code you would otherwise run with no restriction, such as your own agent’s output, generated scripts or in-house plugins, the profile removes compile-time execution and refuses reflection, dynamic dispatch, process execution, dynamic evaluation and ambient I/O unless granted. It combines features listed under False friends without changing their status. It is not a sandbox: for code from an untrusted author it is one layer beneath process or OS isolation, and §3 still applies.
The name says "restricted", not "secure" or "safe", for this reason.
Related work in Groovy
GEP-31: Realm based dynamic semantics
GEP-31 lets a realm define its extension method set, its MetaClass layer and its object MOP, and states
that static method selection uses the realm’s extension set. A restricted realm that drops
ProcessGroovyMethods and ResourceGroovyMethods, has no MetaClass layer and excludes invokeMethod would
express the GDK and dispatch rules of this profile structurally. "ls".execute() would simply not resolve,
and the rule would hold for dynamically compiled code as well. If GEP-31 lands, the profile should define
such a realm and shrink RestrictedEffectsChecker to the JDK-type rules. This GEP does not depend on
GEP-31. It is a concrete use case for it.
GEP-25: Data-flow security
GEP-25 tracks where values go: taint inward, sensitive values outward. This GEP restricts what code may do at all. They are complementary and kept separate so that neither waits on the other. Later agent-harness work is expected to combine them, with GEP-25’s sensitive-value carrier used from snippets compiled under this profile.
Typed binding for REPL hosts (GROOVY-12462)
Under forced static compilation, a REPL snippet that reads a session variable from its Binding (x + 1)
is refused as undeclared. GROOVY-12462 proposes three small parts that REPL hosts add to their
configuration:
-
resolve: an
unresolvedVariabletype-checking extension looks names up in a session map of name to type; -
export: top-level declarations stay ordinary locals while compiling, so flow typing applies, and are copied into the binding at the end of the snippet;
-
record: after type checking, the inferred type of each exported declaration, generics included, is stored in the session map for later snippets.
A prototype on Groovy 6.0.0 compiled n + names.size() statically against recorded types int and
ArrayList<String>, and refused names.first().foo() and incompatible reassignments. Resolving from runtime
classes alone loses generics, which is why the record step reads inferred types instead. This is useful
without the profile, as an optional static mode for groovysh and notebook kernels. The preset can include
it when it exists, and LACUNA-style holes filled at runtime would need it.
Spec sync and @Spec (outside core)
Martin Odersky’s Scala Days 2026 keynote shows program holes filled ahead of time: a method with a typed
signature, a prose spec string and ???, whose body an agent writes into the source during a "sync" step.
The Groovy form under discussion is @Spec('''…''') on a method with an empty body, next to @Requires
and @Ensures. A sync tool, starting outside core in groovy-verify or a small module, fills the body,
checks it against the contracts and writes it back. Two points concern this GEP:
-
filling must be a tool step, never an AST transform that calls a model, so that compiling still runs nothing;
-
the tool compiles each candidate under this profile in per-method scope, with
@Specas the marker, so generated bodies are restricted while the hand-written code around them is not.
PurityChecker closure parameters (GROOVY-12460)
The ticket letting @Pure apply to closure and SAM parameters is independent. Hosts that enable
PurityChecker under the profile benefit from it. GROOVY-12464 closes a related gap: mutation through operators
(<<, subscript assignment, and GEP-15 *Assign methods such as plusAssign) is not yet
detected, while the equivalent method calls are.
Excluded and deferred features
-
A sandbox. Out of scope by design; see Positioning.
-
Runtime enforcement. The profile is compile-time only. Libraries on the classpath may do anything internally; a granted or allowed library call is trusted. Hosts that need more use process isolation.
-
Resource limits. Memory, CPU and output size are host concerns.
TimedInterruptis offered for accidental loops only. -
Path-scoped and capability grants. Scoped grants such as "read under
/workonly", and a check that a granted handle does not escape the block it was given in, are deferred to a later GEP or ticket that builds on the grant vocabulary here. -
Dynamic code. Without static compilation the checker cannot see dispatch targets. The profile forces static compilation; a realm (GEP-31) is the path to restricting dynamic code.
Compatibility
Purely additive. Nothing changes for code not compiled with the profile or the checker. Users who already
combine SecureASTCustomizer with @CompileStatic can keep doing so.
Risks, maintenance and status
-
Being mistaken for a sandbox. The largest risk. It is mitigated by the name, the warning box, the threat-model wording and documentation that points to process isolation on every page.
-
Coverage gaps. A type-checking extension sees only what the compiler resolves. The suite exists to make gaps visible; each newly found bypass becomes a test.
-
List maintenance. The deny list needs updating as the JDK and GDK grow. Sharing lists with
PurityCheckerand, later, a realm definition keeps it to one place. -
Usability. A profile nobody can use is no profile. The must-compile half of the suite guards this.
Status: Draft. The suggested order of work is the refused-snippet suite first, as failing tests; then the checker until the suite passes; then the preset, documentation and threat-model row. The typed-binding ticket proceeds in parallel, prototyped in the groovy-jupyter kernel.
References
-
THREAT_MODEL.md §3 (scope), §5a (operational knobs), §9 (false friends), in the Groovy repository
-
TACIT: capability-safe agent code in Scala (safe mode and scoped capabilities)
-
Martin Odersky, "The End of Programming?", Scala Days 2026 keynote (spec holes filled ahead of time)
Update history
- 1 (2026-10-19)
-
Initial draft, including Grape probe results, per-method scope, the revised typed binding design and the relation to spec sync, with per-method and loop scope probe results