Status: proposal / findings, 2026-07-10
This document summarizes an analysis of the core module and proposes an embedding API
that lets a Java (enterprise) application run Turicum scripts sandboxed, with limits on
execution time, memory, CPU use, Java call-outs, and other resources.
REFERENCE.adoc already announces: "Richer embedding and sandboxing APIs, including
limiting a program's access to the JDK and system resources, are under active
development." This design fills in that promise.
The codebase is halfway there and, importantly, has clean chokepoints.
| Mechanism | Where | Notes |
|---|---|---|
| Global step limit | GlobalContext(stepLimit, graceSteps) |
Checked on every command |
| Per-thread step limit | ThreadContext.setStepLimit() |
Independent of the global limit; used by the steps option of async |
| Step chokepoint | LocalContext.step() (LocalContext.java:810) |
Calls both global and thread step(); every command executes it |
| Uncatchable halts | InterpreterHalt ← StepLimitReached, ExecutionAborted |
Script-level try/catch cannot swallow them; converted to ExecutionException at the Interpreter.execute boundary |
| Bounded cleanup after halt | Grace (memory/Grace.java) |
finally/with exit blocks get a bounded step allowance after a halt; cannot run forever |
| Cooperative abort | ThreadContext.abort() |
Sets a flag checked at every command start and interrupts the thread (unblocks I/O and channel waits) — exactly the primitive a watchdog needs |
| Thread registry & join | GlobalContext.contexts, joinThreads() |
Already aborts and joins all child threads at the end of Interpreter.execute |
| Per-interpreter class loader | GlobalContext.classLoader (TuricumClassLoader) |
Nearly all reflection funnels through it: java_class, java_call, java_object, is_type, and type annotations in Variable resolve classes via ctx.globalContext.classLoader.loadClass(...) |
| Pluggable builtins | ServiceLoader SPI (TuriFunction, TuriMacro, TuriClass), registered per context in BuiltIns.register() |
A natural place for capability filtering |
| Host → script data | Interpreter.compileAndExecute(Map<String,Object>) |
Injects variables as frozen globals |
| Compile once / run many | Program is immutable; Marshaller/Unmarshaller serialize to .turc |
Enables precompiled, reusable programs and even out-of-process execution |
Interpreternever exposes the limits.Interpreter.compile()doesctx = new LocalContext()— unlimited steps, no grace. An embedder cannot set any limit without reaching into the internalch.turic.memorypackage.- No wall-clock timeout. Steps are a poor proxy for time:
sleep(), blocked channel reads, and slow Java call-outs consume essentially no steps. - No memory accounting of any kind. A script can OOM the host JVM with one growing list or string.
- No CPU throttling.
- Java access is all-or-nothing. With
java_classregistered, a script can reachjava.lang.Runtime,ProcessBuilder,java.nio.file.Files— anything. It could even loadch.turic.memory.GlobalContextand lift its own limits. - Side-effecting builtins are always on:
env,glob,import/sys_import(filesystem reads),http_client,http_server;printwrites directly toSystem.out(Print.java:41). - Unbounded thread spawning.
AsyncEvaluationuses one static, JVM-wideExecutors.newVirtualThreadPerTaskExecutor()(AsyncEvaluation.java:13): no per-interpreter cap, no isolation between interpreters, no deterministic shutdown. - No recursion-depth limit — deep script recursion kills the host thread with a
raw
StackOverflowError.
One new public package ch.turic.embed containing a policy object and a session
facade. Everything else stays internal.
var policy = SandboxPolicy.builder()
// ---- time ----
.stepLimit(10_000_000) // exists today, just not exposed
.graceSteps(10_000) // exists today
.timeout(Duration.ofSeconds(5)) // NEW: wall-clock watchdog -> abort()
// ---- CPU ----
.cpuThrottle(0.25) // NEW: max fraction of one core
// ---- memory (approximate; see §4.3) ----
.maxCollectionSize(1_000_000) // NEW: cap on a single list/object/string
.maxAllocatedCells(50_000_000) // NEW: global allocation counter
// ---- concurrency ----
.maxThreads(8) // NEW: per-engine bounded executor
.maxStackDepth(2_000) // NEW: cap ThreadContext trace depth
// ---- capabilities (sandbox default: all OFF) ----
.allow(Capability.JAVA_REFLECTION) // gates java_class / java_call / ...
.javaClassFilter(name -> // consulted even when reflection is on
name.startsWith("com.mycorp.scripting.api."))
.allow(Capability.FILE_READ) // gates import, glob, sys_import
.importRoot(Path.of("/opt/scripts"))// imports resolved only under this root
// NETWORK, ENV, PROCESS, FILE_WRITE remain denied
// ---- I/O redirection ----
.stdout(myWriter).stderr(myWriter).stdin(myReader)
.build();
try (TuriEngine engine = TuriEngine.create(policy)) {
TuriProgram program = engine.compile(source); // reusable, thread-safe
try (TuriSession session = engine.newSession()) {
session.set("request", requestData); // frozen injected global
Object result = session.eval(program);
// throws TuriTimeoutException, TuriResourceException, BadSyntax, ...
}
}Design points:
TuriEngine— holds theSandboxPolicy, the bounded executor, the watchdog scheduler, and the compile cache. Compile once, evaluate many times (Programis already immutable and serializable).TuriSession— wraps the lifetime of oneLocalContext(AutoCloseable, likeInterpretertoday). Offersset()for frozen injected globals andeval()/evalAsync()(returningCompletableFuture) so the host never has to block on a runaway script.- Typed exceptions —
TuriTimeoutException,TuriResourceException(step, memory, thread, stack limits), distinguishable from ordinary script errors (ExecutionException) and syntax errors (BadSyntax). - The existing
Interpreterclass stays as-is for CLI/tools usage;TuriEnginebecomes the documented entry point for embedders.
- Add a
deadline(nanoseconds) toGlobalContext. - A single shared daemon
ScheduledExecutorServiceinTuriEngineschedules one task per evaluation that calls a newGlobalContext.abortAll()— iterate the existingcontextsregistry and callThreadContext.abort()on each. - Everything downstream already works: unwinding via
ExecutionAborted, boundedfinallycleanup viaGrace, thread joining viajoinThreads(). - Belt-and-braces: check the deadline inside
LocalContext.step()every ~1024 steps (counter mask — one amortized branch) in case the watchdog thread is delayed.
- Piggyback on the same step chokepoint: a token bucket in
GlobalContext. - Every N steps compare elapsed wall time against the budget implied by
cpuThrottle; if ahead of budget,LockSupport.parkNanos(...). - Limitation to document: this throttles interpreter work, not time spent inside a single Java call-out.
- Do not attempt
ThreadMXBeanper-thread CPU time — it is unsupported for virtual threads, which is whatAsyncEvaluationuses.
Hard in-process memory isolation does not exist on the JVM (the SecurityManager is gone, JEP 411). What enterprise embedders actually need is to stop the one-growing-list script from OOMing the host. Offer:
- An
AtomicLongallocation counter onGlobalContext, incremented with rough size weights at the few allocation sites:LngListgrowth,LngObject.setField, string concatenation/interpolation, channel buffering. - Per-object caps (
maxCollectionSize, max string length) enforced at the same sites. - A new
MemoryLimitReached extends InterpreterHaltso it is uncatchable in-script and gets the sameGracecleanup treatment. - Documentation: a hard guarantee requires process isolation — a separate JVM with
-Xmx(the.turcserialization makes a subprocess runner module easy to add later) or a container.
Layer 1 — capability-gated builtins.
Add default Set<Capability> capabilities() { return Set.of(); } to the
TuriFunction/TuriMacro/TuriClass SPI (via ServiceLoaded or a sibling
interface) and tag the dangerous ones:
| Capability | Builtins |
|---|---|
JAVA_REFLECTION |
java_class, java_call, java_object, java_import, java_callback, add_java_classes, java_resources, java_type, as_object |
FILE_READ |
import, sys_import, glob, source_directory, TuriInputStream/Reader classes |
ENV |
env |
NETWORK |
http_client, http_server |
BuiltIns.register(ctx, policy) skips builtins whose capabilities are not granted;
the script then gets an ordinary "undefined symbol" error.
Layer 2 — class filter at the loader.
Even with reflection granted, give TuricumClassLoader a Predicate<String> filter;
loadClass/findClass throw ClassNotFoundException for denied names. Because all
class resolution funnels through globalContext.classLoader, this single change
covers java_class, java_call, java_object, is_type, and Variable type
annotations. Apply a built-in deny-list even in permissive mode:
java.lang.Runtime,java.lang.ProcessBuilder,java.lang.System(partially)java.lang.reflect.*,java.lang.invoke.*sun.*,jdk.internal.*ch.turic.*internals — otherwise a script can loadGlobalContextand lift its own limits.
- Thread cap. Move the executor from the static field in
AsyncEvaluationtoGlobalContext: a virtual-thread executor guarded bySemaphore(maxThreads); failure to acquire throwsExecutionException. Also fixes the current cross-interpreter sharing and givesTuriEngine.close()deterministic shutdown. - Stack depth.
ThreadContext.push()throws an uncatchable halt pastmaxStackDepthinstead of letting aStackOverflowErrorhit the host thread. - I/O redirection. Give
GlobalContextout/err/instreams (defaulting to theSystemstreams);Printand the input-stream builtins use them. Needed for any server-side embedding regardless of sandboxing. - Import root.
Importcurrently callsFiles.readStringon a resolved path; resolve againstpolicy.importRoot(), reject..escapes, deny entirely withoutFILE_READ.
- SecurityManager — removed from the JDK; not an option.
- Exact CPU-% enforcement — the token bucket is a throttle, not an OS scheduler guarantee; Java call-outs are not throttled mid-call.
- Hard memory isolation in-process — accounting is approximate; hard limits need
a subprocess/container (possible later module using
.turc+-Xmx).
- Phase 1 — plumbing (unblocks embedders immediately). (implemented 2026-07-10)
SandboxPolicy+TuriEngine/TuriSessionexposing what already exists (step limit, grace steps, injected globals) plus the wall-clock timeout, the per-engine thread cap, and I/O redirection. Implementation notes: new packagech.turic.embed(SandboxPolicy,TuriEngine,TuriProgram,TuriSession,TuriTimeoutException);GlobalContextgainedout()/err()redirection, a per-contextexecutor()(default: JVM-shared virtual-thread executor), aSemaphore-based thread-permit pool shareable across sessions for an engine-wide cap, andabortAll();LocalContext(GlobalContext)root constructor added;AsyncEvaluationandFlowCommandnow use the per-context executor and acquire/release thread permits;Printwrites toglobalContext.out(). Tests:core/src/test/java/ch/turic/embed/TestTuriEngine.java. - Phase 2 — capabilities.
Capability-gated builtin registration, the class filter on
TuricumClassLoaderwith the default deny-list, and the import root. Specified in detail in §7. - Phase 3 — resource accounting. Memory accounting and per-object caps, the CPU token bucket, and the stack-depth limit.
Status: agreed design, 2026-07-12. Not yet implemented.
Every engine has a SandboxPolicy (TuriEngine.create() uses
SandboxPolicy.UNRESTRICTED), so "a policy object is present" does mean "distrust the
script". Nor may the arrival of Phase 2 silently change what an existing policy means: a
Phase 1 embedder who configured only .timeout(...) for trusted in-house scripts must not
lose java_class, import, and env on upgrade. Resource limits and capability
restrictions are orthogonal — bounding how long a script runs says nothing about what it
may reach.
Therefore the trust stance is chosen explicitly at the builder entry point, and the two modes get opposite defaults and opposite list semantics:
SandboxPolicy.trusted() |
SandboxPolicy.untrusted() |
|
|---|---|---|
| Intended for | in-house automation, build scripts, configuration | user-supplied scripts, multi-tenant servers |
| Capability default | all granted | none granted |
| Lists act as | deny-list (deny… methods) |
allow-list (allow… methods) |
| Security posture | guardrails, not a security boundary | security boundary (within JVM limits, see §5) |
| Deny floor (§7.4) | on, overridable via unsafeAllow… |
absolute |
The former builder() entry point is removed (renamed to trusted()): its name
carried no stance, and after Phase 2 every policy has one. SandboxPolicy.UNRESTRICTED
remains and equals trusted().build(). This is a source-incompatible rename of a
not-yet-released API; the Phase 1 tests and EMBEDDING.md snippets are updated with it.
Naming: trusted()/untrusted() name the thing that actually differs — how much the
embedder trusts the script — and are true opposites. Considered and rejected:
sandbox() (stutters as SandboxPolicy.sandbox(), and is not the opposite of anything),
permissive()/restrictive() (describe the mechanism, not the reason),
allowByDefault()/denyByDefault() (unambiguous but verbose; kept instead as the
queryable property policy.isDenyByDefault()).
A single builder with a mode flag would let allow… and deny… calls silently change
meaning depending on a distant line. Instead the entry points return different builder
types sharing a common base:
public sealed abstract class Builder<B extends Builder<B>> { // shared, mode-independent
public B stepLimit(int limit) …
public B graceSteps(int steps) …
public B timeout(Duration timeout) …
public B maxThreads(int max) … public B singleThread() …
public B stdout(…) … public B stderr(…) …
public SandboxPolicy build() …
}
public final class TrustedBuilder extends Builder<TrustedBuilder> {
public TrustedBuilder deny(Capability... capabilities) …
public TrustedBuilder denyJavaClasses(String... patterns) …
public TrustedBuilder unsafeAllowJavaClasses(String... patterns) … // pierces the floor
}
public final class UntrustedBuilder extends Builder<UntrustedBuilder> {
public UntrustedBuilder allow(Capability... capabilities) …
public UntrustedBuilder allowJavaClasses(String... patterns) …
public UntrustedBuilder importRoot(Path root) … // required if FILE_READ is granted
}Calling the wrong family is a compile-time error, and reading a policy declaration tells you its stance in the first line:
// Mode 1 — trusted: guardrails only
var policy = SandboxPolicy.trusted()
.timeout(Duration.ofSeconds(30))
.deny(Capability.NETWORK)
.build();
// Mode 2 — untrusted: default deny
var policy = SandboxPolicy.untrusted()
.stepLimit(10_000_000)
.timeout(Duration.ofSeconds(5))
.allow(Capability.JAVA_REFLECTION)
.allowJavaClasses("com.mycorp.scripting.api.*")
.build();public enum Capability { JAVA_REFLECTION, FILE_READ, ENV, NETWORK }The SPI (TuriFunction/TuriMacro/TuriClass, via ServiceLoaded) gains
default Set<Capability> capabilities() { return Set.of(); }; builtins whose set is
non-empty are registered by BuiltIns.register(ctx, policy) only when every listed
capability is granted. A script calling an unregistered builtin gets the ordinary
"undefined symbol" error. Tagging of the existing builtins:
| Capability | Builtins |
|---|---|
JAVA_REFLECTION |
java_class, java_call, java_object, java_import, java_callback, add_java_classes, java_resources, java_type, as_object |
FILE_READ |
import, sys_import, glob, source_directory, TuriInputStream, TuriInputStreamReader |
ENV |
env |
NETWORK |
http_client, http_server |
The enum leaves room for FILE_WRITE and PROCESS when such builtins appear; today both
are reachable only through reflection and are therefore handled by the class filter and
the floor.
Granting JAVA_REFLECTION answers whether the reflection builtins exist; the class
filter answers which classes they may touch. All class resolution already funnels
through GlobalContext.classLoader (TuricumClassLoader), which gains a filter consulted
in loadClass/findClass; a denied name fails exactly like a missing class, but with an
attributable message: Java access to 'java.lang.Runtime' denied by the sandbox policy (untrusted mode). Patterns are FQCN literals or package prefixes with a trailing .*.
Independent of mode, a mandatory deny floor is always installed:
ch.turic.*— a script that can loadGlobalContextlifts its own limits;jdk.internal.*,sun.*— JDK internals;java.lang.reflect.*,java.lang.invoke.*— reflection escape hatches that would bypass the filter one level down;java.lang.Runtime,java.lang.ProcessBuilder,java.lang.System,java.lang.Thread(and subclasses’ packagesjava.util.concurrent.*stay allowed only in trusted mode) — process, exit, environment, and raw-thread escape hatches; raw threads would also bypass themaxThreadspermit accounting.
In untrusted mode the floor is absolute — there is deliberately no API to pierce it.
In trusted mode it is on by default and can be pierced per pattern with
unsafeAllowJavaClasses(...); the unsafe prefix exists to be greppable in code review.
Why ch.turic.* is on the floor. This entry is defense-in-depth, not the plugging of
a single proven exploit — and worth spelling out because it is the least obvious floor member.
The ch.turic.* package contains the very machinery that enforces the sandbox: GlobalContext
holds the steps counter and stepLimit, the granted-capability set, and the importRoot;
TuricumClassLoader holds the class filter itself and can be disarmed with
setScriptClassFilter(null, …). If a script could reach the live instances of these, it
would own its own cage — reset the step counter, null the filter, unscope file access. That
reach is not demonstrated by loading the class alone (java_class yields the Class and lets
you construct a new instance or call statics, not grab the running interpreter's context),
and java.lang.reflect.*/java.lang.invoke.* — the tools you would use to walk from a class
to a live field — are floored too. So the entry is belt-and-suspenders on top of the reflection
floor. The whole package is blocked rather than audited class by class because (a) several
ch.turic.* classes carry process-wide static state (the shared virtual-thread executor, the
TuriClass registry, CycleGuard's thread-locals) that a script could corrupt or use to reach
across sessions without ever touching the live context; (b) a per-class audit is fragile and
would need redoing on every refactor, whereas a package prefix cannot rot; and (c) the cost to
legitimate scripts is exactly zero — no sandboxed Turicum script has any reason to reflect on
interpreter internals, so the block is free insurance with high downside if omitted. The floor
applies only to script-initiated loads (loadClassForScript / checkScriptAccess); the
interpreter's own use of its classes goes through normal JVM loading and is never filtered.
Granularity honesty. The enforceable chokepoint is class loading, so the permission
unit is the class, and an allowed class exposes all its public members. The documentation
must say plainly: do not allowlist broad utility classes; write a facade class in the host
application with exactly the operations scripts may perform, and allowlist that. Method-level
filtering (interception inside java_call/java_object) is possible at a later phase but out
of scope for Phase 2.
Finding (2026-07-12): reflective escape via held Java objects — fixed. The first
implementation filtered only class lookups by name (loadClassForScript, used by
java_class/java_call/java_object/type annotations). But the interpreter also reaches Java
methods by a second path: LeftValue.toObject auto-wraps any raw Java object into a
memory.JavaObject, and FunctionCall then dispatches obj.method(args) by raw reflection
(Reflection.invoke) without consulting the filter. Any object a script already holds —
most importantly one the embedder injects with TuriSession.set(...), i.e. the recommended
facade — was therefore a full reflective foothold: Object.getClass() is inherited by
everything, so
host.getClass().getClassLoader().loadClass("java.lang.Runtime") reached an arbitrary class
even in untrusted mode with JAVA_REFLECTION denied and the class allowlist in force. This
was verified end-to-end. (The earlier claim in this section that "injected host objects are
reachable regardless of the filter" was exactly the hole.)
Fix: the class filter now governs reflective member access too, not only load-by-name.
TuricumClassLoader.checkScriptAccess(Class) applies the same predicate to the runtime class
of the target of every reflective method call (FunctionCall, both the JavaClass static and
JavaObject instance branches) and field read (JavaObject.getField). java.lang.Class and
java.lang.ClassLoader are added to the absolute floor. Net effect: an injected object is
method-callable only when its class is allowlisted (the facade pattern still works), while
getClass() returns an inert Class whose reflective methods are floored, so the pivot to an
arbitrary class is closed. Full trust (UNRESTRICTED, and the plain Interpreter, which
install no filter) is unaffected. Tests:
injectedObjectsAreReachableOnlyWhenAllowlisted,
anInjectedObjectCannotBePivotedIntoAReflectiveEscape.
With FILE_READ granted in untrusted mode, importRoot(Path) is required (building
without it is an IllegalStateException). In trusted mode importRoot is optional and
merely convenient.
importRoot is a ceiling on the result, not a replacement search path (revised
2026-07-12). The normal APPIA search runs unchanged — driven by the script's own
global APPIA or the host environment — and the file it resolves is then required to lie
under the root: the located path is absolutized and normalized (collapsing ..) and must
startsWith the root, otherwise it is denied as a policy violation rather than reported as
"not found". Confinement holds regardless of how APPIA points, because the check is applied
to the result: a script that sets global APPIA to a directory outside the root still has
its reads refused. Enforced in AppiaHandler.locateSource via confineToImportRoot.
An earlier revision made importRoot replace APPIA with a single search root. That was
changed because it altered resolution semantics (a script could resolve imports differently
under the sandbox than during development) and could not express more than one search
directory. The trade-off of the ceiling model: since the root no longer contributes a search
location, the embedder must ensure APPIA (script global or host environment) includes at
least one directory under the root, or nothing resolves. Note the confinement is a lexical
startsWith after normalization; it does not resolve symlinks, so a symlink planted under the
root that points outside it would be followed — out of scope for the ..-escape threat model,
but a toRealPath() variant is the hardening if symlink defense is later required.
policy.isDenyByDefault()reports the stance;TuriEngine/TuriSessionexpose the policy they run under.- Every capability- or filter-denied operation names the denied thing, the mechanism, and the mode in its error message — a sandbox whose refusals look like random script bugs ("undefined symbol", "class not found") wastes exactly the debugging time it should save. The "undefined symbol" behavior of unregistered builtins (§7.3) is the one deliberate exception: hiding a builtin entirely is the point of not registering it.