This guide documents the security boundary for runtime Lua execution in CoreAI.
It is written for teams that expose Lua through LuaTool, custom runtime bindings,
or AI-authored gameplay scripts.
Lua is allowed to request gameplay changes. C# decides whether those changes are legal.
That single rule keeps the integration understandable. The sandbox should make scripts useful for gameplay iteration without turning them into a back door into files, processes, networking, Unity internals, or server authority.
CoreAI treats Lua as untrusted gameplay logic:
- AI output and player-provided script text must be validated before execution.
- Lua scripts may read and write only APIs explicitly registered by the host.
- Scripts must be bounded by instruction and timeout limits.
- Host bindings are responsible for domain validation, authority, and rollback.
The sandbox is a defense layer, not a permission system for sensitive server-side operations.
Lua is an optional module. Defining the scripting symbol COREAI_NO_LUA
(Project Settings → Player → Scripting Define Symbols) compiles the entire
Lua-CSharp-based sandbox out of CoreAI.Core and CoreAI.Source, mirroring the
existing COREAI_NO_LLM opt-out. The core (orchestration, LLM, chat, agent
memory) builds and runs with no Lua-CSharp usage. Lua ships bundled as
Lua.dll / Lua.Annotations.dll inside the CoreAI Mods package
(Assets/CoreAIMods/Plugins/) — there is no external package dependency to
remove.
When COREAI_NO_LUA is set:
LuaCsSecureEnvironment,LuaCsCoroutineHandle,LuaCsApiRegistry,LuaCsExecutionGuard,LuaCsAiEnvelopeProcessor, andLuaCsCoroutineRunner(all part of the CoreAI Mods package) are removed from the build.CorePortableInstaller/WorldCommandsInstallerskip Lua registrations and fall back to the Core no-opsCoreDefaultLuaRuntimeBindings/NullLuaExecutionObserver, so the DI graph still resolves.AiGameCommandRouterdrops itsLuaAiEnvelopeProcessordependency; AI command routing degrades to world-command execution only.
Default builds (symbol unset) keep Lua enabled and behave exactly as before.
Runtime Lua/world-command scene permissions are owned by an optional CoreAiLuaWorldModule child of
CoreAILifetimeScope. The module contains the prefab whitelist, scene whitelist, Full-tier grant, and
private-member grant. This keeps security-sensitive Lua configuration out of the root CoreAI Inspector
unless the host deliberately adds the module. Existing scenes with the former flat fields migrate without
losing serialized values; new scenes should configure only the child module.
.github/workflows/ci.yml runs EditMode tests in both configurations on every
push/PR: the default project with Lua enabled, and a no-lua job that appends
COREAI_NO_LUA to every platform's Scripting Define Symbols before
compiling — exactly the opt-out procedure described above. The default job
additionally asserts that the SecureLuaSandboxEditModeTests escape-test
fixture actually executed, so isolation coverage cannot silently drop out of
the suite. The workflow needs the standard GameCI secrets (UNITY_LICENSE,
UNITY_EMAIL, UNITY_PASSWORD) configured in the repository.
Lua generation is constrained at multiple stages:
LuaAiEnvelopeProcessorenforces a sliding-window limiter (LuaGenerationRateLimiter, default 20 per 60 s) for envelope runs and scheduled Programmer repair generations.LuaTool/execute_luaalso enforces rate limiting in the tool path. The envelope andexecute_luashare a limiter when the same limiter is injected, so a busy model or repair loop is blocked consistently across both layers.
LuaGenerationRateLimiter is default-on. maxPerWindow <= 0 still disables it.
When the limiter is saturated, the envelope fails with Lua rate limit exceeded and repair is skipped, so a failing script cannot spin a runaway generate→fail→repair loop against the LLM.
string.repis now capped via a replaced implementation inSecureLuaEnvironment.SecureLuaEnvironment.MaxStringRepLengthis1_000_000; attempts that exceed this limit fail fast with an explicit error.table.concatis capped the same way (MaxTableConcatLength, same1_000_000value): the replacement mirrors the realtable.concatalgorithm for both VMs but aborts as soon as the in-progress result exceeds the cap, instead of finishing a potentially huge build first.- Total per-execution allocation budget (default 64MB, F-08).
string.rep,string.format, andtable.concatare capped at their library call site, but plain string concatenation (s = s .. s) is ordinary VM opcodes with no call site to intercept — a ~1MB allowed string can be doubled repeatedly into hundreds of MB well within the instruction budget. The per-instruction hook (LuaCsExecutionGuard) now also samplesGC.GetAllocatedBytesForCurrentThread()against a baseline captured at the start of the guarded call and aborts withEXCEEDED_MEMORY_BUDGETonce the delta exceeds the budget. This check runs on every instruction, not on a coarse sample interval: concatenation doubling grows exponentially, so a handful of loop iterations can jump from megabytes to gigabytes, and any sampling interval wide enough to matter for performance is also wide enough to either miss the attack or let the runtime approach a real out-of-memory condition before the next sample point.GC.GetAllocatedBytesForCurrentThread()is a thread-local counter read (not a GC pass), the same cost class as the wall-clock check already performed unconditionally on the same hot path, so this adds no new performance risk. Configure the budget via themaxAllocatedBytesconstructor parameter onLuaCsExecutionGuard, orLuaCsSecureEnvironment.MaxAllocatedBytesBudgetfor the default.- What this does not cover: the budget is a total-allocation backstop, not a live heap-size cap —
a script that allocates and discards memory in a tight loop can still cause GC churn without
tripping it (GC pauses are bounded by the existing timeout instead). A single host callback that
allocates a large amount of memory in one call (not driven by VM instructions) is not observed by
this hook either; host bindings must still bound their own worst-case allocations, the same caveat
that already applies to the wall-clock guarantee documented on
LuaCsExecutionGuard.
- What this does not cover: the budget is a total-allocation backstop, not a live heap-size cap —
a script that allocates and discards memory in a tight loop can still cause GC churn without
tripping it (GC pauses are bounded by the existing timeout instead). A single host callback that
allocates a large amount of memory in one call (not driven by VM instructions) is not observed by
this hook either; host bindings must still bound their own worst-case allocations, the same caveat
that already applies to the wall-clock guarantee documented on
- Coroutine abuse has a total-lifetime budget through
LuaCoroutineHandle.LuaCoroutineHandle.DefaultTotalLifetimeStepsis1_000_000across all resumes for one handle, and the handle is forcibly killed when exceeded. LuaAiEnvelopeProcessornormalizes and truncates results: result summary is capped at 4,000 characters and error messages are normalized and capped at 500 characters before entering the payload/repair path.LuaCsApiRegistrywraps host callbacks and converts host validation exceptions intoLuaRuntimeException, so Lua callers see script errors instead of raw CLR exception types.coreai_world_load_scenesupports an optional scene whitelist check.- World bindings validate coordinate inputs before touching state:
coordinates must be finite and
abs(value) <= 100000. time_set_scalevalidates input:NaN/Infinityare rejected and valid scales are clamped to[0, 10].play_soundclamps volume to[0, 1].
Runtime Lua execution is supported on all platforms, including WebGL player
builds. On WebGL, Lua is on by default — toggle with
CoreAISettingsAsset.EnableLuaOnWebGl. The Full unity_* reflection tier
stays disabled on WebGL; IL2CPP stripping protection (link.xml preserving
Lua.dll / Lua.Annotations.dll) ships in the package.
- Register only the bindings needed for the current scene or game mode.
- Validate every binding argument in C# before touching game state.
- Run the script through timeout and instruction limits.
- Return a compact structured result to the LLM.
- Persist only host-approved state changes.
The sandbox must not expose APIs that allow file, process, reflection, or runtime escape by default:
io,os,debug,package,require,loadfile, anddofile.- Arbitrary CLR/Unity reflection entry points.
- Direct filesystem, networking, shell, or environment access.
- Host object references that expose broad mutable state without a narrow wrapper.
If a game needs one of these capabilities, wrap it in a purpose-built C# binding with explicit validation and tests.
Every script path should have bounded execution:
- Instruction step budget for CPU-bound loops.
- Timeout/cancellation token for host-driven async flows.
- Coroutine lifecycle ownership, including cleanup on scene unload and game reset.
- Per-agent or per-session rate limits when scripts can be generated repeatedly.
The host should treat a timeout as a failed script execution and return a compact, structured error to the LLM instead of retrying indefinitely.
Prefer small, deterministic bindings:
- Use narrow method names such as
spawn_prefab,set_stat, oraward_item. - Validate all ids, enum values, numeric ranges, positions, layers, and ownership.
- Return structured results:
{ ok = true }or{ ok = false, error = "..." }. - Make mutating calls idempotent when practical.
- Keep authority in C#; Lua requests changes, C# decides whether they are legal.
Avoid passing Unity objects directly into Lua. Use ids or handles, then resolve and validate them in C#.
Prefer a small verb that maps to a validated host operation:
spawn_prefab("training_dummy", 4, 0, 12)The C# binding should still check the prefab id, scene permissions, position, collision rules, budget limits, and ownership before spawning anything. Lua gets a clean result; the host keeps authority:
{
"ok": true,
"object_id": "dummy_042"
}When validation fails, return a result the model can repair:
{
"ok": false,
"error": "blocked_spawn_position",
"message": "The requested position overlaps a non-trigger collider."
}Maintain EditMode tests for attempts to:
- Access
io,os,debug,package,require,loadfile, ordofile. - Reconstruct globals through
_G,_ENV, metatables, or debug-style helpers. - Use
string.dump, coroutine APIs, or garbage collection as escape/timing probes. - Run infinite loops, deep recursion, or huge table allocations.
- Call host bindings with invalid ids, extreme numbers, NaN/Infinity, or oversized strings.
- Reuse stale object handles after scene reload or despawn.
string.reprepeat-capping behavior (MaxStringRepLength) and malformedpackageaccess checks.table.concatoutput capping (MaxTableConcatLength) and plain-concatenation allocation bombs (s = s .. sdoubling) hitting the total per-execution allocation budget (EXCEEDED_MEMORY_BUDGET).pcallloop recursion and unbounded call-stack behavior.- Coroutines that exceed total lifetime budgets.
- World binding validations for NaN/Infinity and coordinate bounds (
|value| <= 100000). - Rate-limit behavior for
execute_luaand repair-generation lockout.
Lua errors should be normalized before they reach the model:
- Include the failed command name and a short error code.
- Avoid dumping full stack traces into prompts by default.
- Do not include secrets, local paths, or server-only implementation details.
- Give repairable errors enough context for one corrective retry.
Example:
{
"ok": false,
"error": "invalid_prefab_id",
"message": "Prefab id 'dragon_boss' is not registered for this scene."
}- Register only the bindings required for the current game mode.
- Keep all mutating operations behind C# validators.
- Run sandbox escape tests when changing Lua runtime setup.
- Run PlayMode tests for coroutine execution, cancellation, scene reload, and reset.
- Document each custom binding with ownership, inputs, outputs, and failure modes.
After reading this guide, you should be able to answer four questions for every Lua binding:
- Who owns the authority for this operation?
- Which inputs can be hostile or malformed?
- What happens on timeout, cancellation, scene unload, or reset?
- What compact error should the LLM receive when the action is denied?
- LUA_NATIVE_APIS.md — which Lua-CSharp features CoreAI uses natively vs custom wrappers.
- LUA_BEST_PRACTICES.md — integration do's and don'ts.
- LUA_GAME_API.md — game API reference.