Skip to content

Commit 6025f8a

Browse files
committed
fix(mods): sandbox reentrancy, transaction-scope reset, tolerant mod headers
Audit wave 2026-07-11, findings 1/8/14: - LuaCsExecutionGuard keeps a per-LuaState hook stack; nested mods_call restores the outer hook instead of disarming it (direct and indirect cycles covered) - LuaCsModRuntime resets the shared ILuaTransactionScope in finally around every handler/timer/load, closing the open-transaction leak - LuaModHeader skips unknown capability tokens (fail-closed to None); ResourcesBundledModSource isolates per-mod load failures
1 parent aa9104d commit 6025f8a

13 files changed

Lines changed: 304 additions & 1888 deletions

Assets/CoreAIMods/Runtime/Infrastructure/LuaCsModRuntimeFactory.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ public static LuaCsModStack Create(LuaCsModStackOptions options)
158158
options.AutoPersistMods,
159159
// Share the version store the gameplay bindings already use: the runtime keys mod history
160160
// under a "mod:" prefix, so it never collides with the coreai_lua_* script slots.
161-
options.LuaScriptVersions);
161+
options.LuaScriptVersions,
162+
// WHY: The bindings are the shared transaction scope of both surfaces; handing them to the
163+
// runtime lets it reset a leaked coreai_world_begin per guarded call, exactly as the
164+
// one-off executor resets around every chunk.
165+
transactionScope: bindings);
162166

163167
LuaCsGameToolExecutor executor = new(
164168
new LuaCsSecureEnvironment(),

Assets/CoreAIMods/Runtime/Infrastructure/ResourcesBundledModSource.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,24 @@ public IReadOnlyList<BundledMod> Load()
4848
continue;
4949
}
5050

51-
LuaModHeader header = LuaModHeader.Parse(asset.text, asset.name);
52-
string id = (header.Id ?? "").Trim();
53-
if (id.Length == 0 || !seen.Add(id))
51+
// WHY: Per-mod isolation: one asset with a malformed header must not throw out of Load and
52+
// kill the seeding of every other bundled mod (and the Hub list with it).
53+
try
5454
{
55-
continue; // no id, or a duplicate id already taken by an earlier asset
56-
}
55+
LuaModHeader header = LuaModHeader.Parse(asset.text, asset.name);
56+
string id = (header.Id ?? "").Trim();
57+
if (id.Length == 0 || !seen.Add(id))
58+
{
59+
continue; // no id, or a duplicate id already taken by an earlier asset
60+
}
5761

58-
mods.Add(new BundledMod(id, asset.text, header.Version));
62+
mods.Add(new BundledMod(id, asset.text, header.Version));
63+
}
64+
catch (System.Exception ex)
65+
{
66+
Debug.LogWarning(
67+
$"[ResourcesBundledModSource] Skipping bundled mod asset '{asset.name}': {ex.Message}");
68+
}
5969
}
6070

6171
return mods;

Assets/CoreAIMods/Runtime/LuaExecution/LuaCsModRuntime.cs

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ private sealed class Mod
132132
private readonly ILuaModStore _store;
133133
private readonly ILuaModSourceStore _sourceStore;
134134
private readonly ILuaScriptVersionStore _versionStore;
135+
private readonly ILuaTransactionScope _transactionScope;
135136
private readonly bool _autoPersistMods;
136137
private readonly ILog _log;
137138
private readonly List<Mod> _tickScratch = new();
@@ -212,6 +213,13 @@ private sealed class Mod
212213
/// <see cref="TryRevertMod"/>. Null falls back to <see cref="NullLuaScriptVersionStore"/> (no history —
213214
/// the prior behaviour).
214215
/// </param>
216+
/// <param name="transactionScope">
217+
/// Optional shared transaction scope of the gameplay bindings behind <paramref name="gameplayBindings"/>.
218+
/// When supplied, the runtime resets it around every load chunk and guarded handler/timer call —
219+
/// mirroring <see cref="LuaCsGameToolExecutor"/> — so a handler that dies between
220+
/// <c>coreai_world_begin</c> and commit cannot leave a stale transaction silently buffering the
221+
/// world commands of later handlers/timers.
222+
/// </param>
215223
public LuaCsModRuntime(
216224
Action<LuaCsApiRegistry, LuaCapabilities> gameplayBindings = null,
217225
ILuaModStore store = null,
@@ -220,14 +228,16 @@ public LuaCsModRuntime(
220228
long handlerMaxSteps = DefaultHandlerMaxSteps,
221229
ILuaModSourceStore sourceStore = null,
222230
bool autoPersistMods = true,
223-
ILuaScriptVersionStore versionStore = null)
231+
ILuaScriptVersionStore versionStore = null,
232+
ILuaTransactionScope transactionScope = null)
224233
{
225234
_gameplayBindings = gameplayBindings;
226235
_store = store;
227236
_log = log;
228237
_sourceStore = sourceStore ?? NullLuaModSourceStore.Instance;
229238
_versionStore = versionStore ?? new NullLuaScriptVersionStore();
230239
_autoPersistMods = autoPersistMods;
240+
_transactionScope = transactionScope;
231241
_handlerGuard = new LuaCsExecutionGuard(handlerTimeoutMs, handlerMaxSteps);
232242
}
233243

@@ -406,11 +416,18 @@ private Mod BuildMod(string modId, string luaCode, LuaCapabilities capabilities)
406416
// during load resolve correctly.
407417
mod.State = _env.Create(registry);
408418

409-
// TODO(migration): reset the ported world/unity transaction scope around the load chunk
410-
// WHY: (the MoonSharp runtime calls ILuaTransactionScope.ResetTransactions() before/after the
411-
// chunk so a transaction left open by a failing load cannot bleed into later scripts).
412-
// There is no ported transaction surface yet, so this is a no-op seam for now.
413-
_env.RunChunk(mod.State, luaCode);
419+
// WHY: Reset the shared transaction scope before/after the load chunk (mirroring the MoonSharp
420+
// runtime) so a transaction left open by a failing load cannot bleed into later scripts —
421+
// and a transaction leaked elsewhere cannot swallow this chunk's world commands.
422+
ResetTransactionScope();
423+
try
424+
{
425+
_env.RunChunk(mod.State, luaCode);
426+
}
427+
finally
428+
{
429+
ResetTransactionScope();
430+
}
414431

415432
return mod;
416433
}
@@ -833,10 +850,31 @@ private void InvokeGuarded(Mod mod, LuaFunction fn, params object[] args)
833850
}
834851
finally
835852
{
836-
// TODO(migration): reset the ported world/unity transaction scope here (the MoonSharp
837-
// WHY: runtime resets ILuaTransactionScope per guarded call so a transaction opened inside one
838-
// invocation cannot leak into the next handler/timer/tick). No-op until gameplay bindings
839-
// are ported.
853+
// WHY: Reset the shared transaction scope per guarded call (mirroring the MoonSharp runtime
854+
// and LuaCsGameToolExecutor) so a transaction opened inside one invocation cannot leak
855+
// into the next handler/timer/tick and silently buffer its world commands.
856+
ResetTransactionScope();
857+
}
858+
}
859+
860+
/// <summary>
861+
/// Discards any unfinished world/data transaction on the shared gameplay-binding scope. Best-effort:
862+
/// it runs inside finally blocks on the tick path, so a throwing scope must not derail the tick.
863+
/// </summary>
864+
private void ResetTransactionScope()
865+
{
866+
if (_transactionScope == null)
867+
{
868+
return;
869+
}
870+
871+
try
872+
{
873+
_transactionScope.ResetTransactions();
874+
}
875+
catch (Exception ex)
876+
{
877+
_log?.Error($"[LuaCsModRuntime] ILuaTransactionScope.ResetTransactions() failed: {ex}");
840878
}
841879
}
842880

Assets/CoreAIMods/Runtime/LuaExecution/LuaModHeader.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,13 @@ private static string NormalizeCapabilities(string value)
309309
LuaCapabilities capabilities = LuaCapabilities.None;
310310
for (int i = 0; i < parts.Length; i++)
311311
{
312-
capabilities |= (LuaCapabilities)Enum.Parse(typeof(LuaCapabilities), parts[i], true);
312+
// WHY: Tolerant parse (mirrors HubModServiceBase.ParseCaps): an unknown token in one
313+
// LLM/hand-written header must not throw out of Parse and take down whole-directory
314+
// loads such as ResourcesBundledModSource; unknown tokens are simply skipped.
315+
if (Enum.TryParse(parts[i], true, out LuaCapabilities parsed))
316+
{
317+
capabilities |= parsed;
318+
}
313319
}
314320

315321
return capabilities.ToString();

Assets/CoreAIMods/Runtime/Sandbox/LuaCsExecutionGuard.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Diagnostics;
4+
using System.Runtime.CompilerServices;
35
using System.Threading;
46
using Lua;
57
using Lua.Runtime;
@@ -26,6 +28,14 @@ public sealed class LuaCsExecutionGuard
2628
/// <summary>Default per-execution GC allocation budget enforced between VM instructions.</summary>
2729
public const long DefaultMaxAllocatedBytesBudget = 256 * 1024 * 1024;
2830

31+
// WHY: Guarded execution can re-enter on the SAME LuaState (mods_call: a self-call, or A calls B
32+
// which calls back into A): a nested finally that simply cleared the hook would disarm the
33+
// limits the still-running outer call depends on, letting the rest of the outer chunk run
34+
// unlimited (sandbox escape). Track the installed hooks per state — shared across guard
35+
// instances via this static table — so a nested guard restores the previous hook on exit and
36+
// the hook is only fully uninstalled when the outermost guarded call unwinds.
37+
private static readonly ConditionalWeakTable<LuaState, Stack<LuaFunction>> InstalledHooks = new();
38+
2939
private readonly int _timeoutMs;
3040
private readonly long _maxSteps;
3141
private readonly long _maxAllocatedBytes;
@@ -148,9 +158,11 @@ private LuaValue[] ExecuteGuarded(
148158
return new System.Threading.Tasks.ValueTask<int>(ctx.Return());
149159
});
150160

151-
state.SetHook(hook, string.Empty, 1);
161+
Stack<LuaFunction> installed = InstalledHooks.GetOrCreateValue(state);
162+
installed.Push(hook);
152163
try
153164
{
165+
state.SetHook(hook, string.Empty, 1);
154166
return body(cancellationToken);
155167
}
156168
catch (LuaRuntimeException)
@@ -159,9 +171,19 @@ private LuaValue[] ExecuteGuarded(
159171
}
160172
finally
161173
{
174+
installed.Pop();
162175
try
163176
{
164-
state.SetHook(null, string.Empty, 0);
177+
if (installed.Count > 0)
178+
{
179+
// WHY: An enclosing guarded call is still running on this state: re-arm ITS hook
180+
// instead of clearing, so the outer step/time/alloc limits stay live.
181+
state.SetHook(installed.Peek(), string.Empty, 1);
182+
}
183+
else
184+
{
185+
state.SetHook(null, string.Empty, 0);
186+
}
165187
}
166188
catch
167189
{

Assets/CoreAIMods/Tests/EditMode/LuaCsModRuntimeEditModeTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,98 @@ public void LuaCs_InterMod_ValueAndFunctionExport_ConsumerReadsAndCalls_Function
304304
"The error should steer the author toward mods_call.");
305305
}
306306

307+
[Test]
308+
[Timeout(15000)]
309+
public void LuaCs_ModsCall_SelfCall_CannotDisarmHandlerGuard()
310+
{
311+
MemoryStore store = new();
312+
LuaCsModStack stack = BuildStack(store);
313+
stack.Runtime.LoadMod("m", @"
314+
mods_export('noop', function() return 1 end)
315+
hooks_on('go', function()
316+
mods_call(mod_id(), 'noop')
317+
local x = 0
318+
for i = 1, 1000000 do x = x + 1 end
319+
store_set('escaped', 'yes')
320+
end)");
321+
322+
stack.Runtime.EmitEvent("go", "");
323+
stack.Runtime.Tick(0);
324+
325+
Assert.AreEqual("", store.Get("m", "escaped"),
326+
"A self mods_call must not disarm the outer guard: the over-budget loop after it must be cut.");
327+
Assert.IsNotEmpty(stack.Runtime.GetRecentHandlerErrors("m"),
328+
"The over-budget handler must fail with a recorded error, not run to completion unlimited.");
329+
}
330+
331+
[Test]
332+
[Timeout(15000)]
333+
public void LuaCs_ModsCall_IndirectCycle_CannotDisarmHandlerGuard()
334+
{
335+
MemoryStore store = new();
336+
LuaCsModStack stack = BuildStack(store);
337+
stack.Runtime.LoadMod("a", @"
338+
mods_export('noop', function() return 1 end)
339+
hooks_on('go', function()
340+
mods_call('b', 'pong')
341+
local x = 0
342+
for i = 1, 1000000 do x = x + 1 end
343+
store_set('escaped', 'yes')
344+
end)");
345+
stack.Runtime.LoadMod("b", "mods_export('pong', function() return mods_call('a', 'noop') end)");
346+
347+
stack.Runtime.EmitEvent("go", "");
348+
stack.Runtime.Tick(0);
349+
350+
Assert.AreEqual("", store.Get("a", "escaped"),
351+
"An A->B->A mods_call cycle must not disarm A's outer guard (self-call bans cannot catch this).");
352+
Assert.IsNotEmpty(stack.Runtime.GetRecentHandlerErrors("a"),
353+
"The over-budget handler must fail with a recorded error.");
354+
}
355+
356+
[Test]
357+
public void LuaCs_HandlerDiesInsideWorldTransaction_NextHandlerCommandStillReachesSink()
358+
{
359+
MemoryStore store = new();
360+
FakeCommandSink sink = new();
361+
LuaCsModStack stack = BuildStack(store, sink);
362+
363+
stack.Runtime.LoadMod("t", @"
364+
hooks_on('leak', function()
365+
coreai_world_begin()
366+
error('dies before commit')
367+
end)
368+
hooks_on('later', function() coreai_world_destroy('victim') end)",
369+
LuaCapabilities.WorldEdit);
370+
371+
stack.Runtime.EmitEvent("leak", "");
372+
stack.Runtime.Tick(0);
373+
Assert.IsNotEmpty(stack.Runtime.GetRecentHandlerErrors("t"), "The leaking handler must fail.");
374+
Assert.AreEqual(0, sink.Commands.Count,
375+
"The aborted transaction's buffered commands must never reach the sink.");
376+
377+
stack.Runtime.EmitEvent("later", "");
378+
stack.Runtime.Tick(0);
379+
Assert.AreEqual(1, sink.Commands.Count,
380+
"A transaction leaked by a dead handler must not silently swallow the next handler's world command.");
381+
}
382+
383+
[Test]
384+
public void LuaCs_LoadChunkDiesInsideWorldTransaction_NextLoadCommandStillReachesSink()
385+
{
386+
FakeCommandSink sink = new();
387+
LuaCsModStack stack = BuildStack(new MemoryStore(), sink);
388+
389+
Assert.Catch(() => stack.Runtime.LoadMod("dying",
390+
"coreai_world_begin()\nerror('dies before commit')",
391+
LuaCapabilities.WorldEdit));
392+
Assert.IsFalse(stack.Runtime.IsLoaded("dying"));
393+
394+
stack.Runtime.LoadMod("healthy", "coreai_world_destroy('victim')", LuaCapabilities.WorldEdit);
395+
Assert.AreEqual(1, sink.Commands.Count,
396+
"A transaction leaked by a failing load chunk must not swallow the next load's world command.");
397+
}
398+
307399
[Test]
308400
public void LuaCs_OneOff_ExecuteReturnsOutput()
309401
{

Assets/CoreAIMods/Tests/EditMode/LuaCsSecureSandboxEditModeTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ public void AllocationBomb_TableConcat_CapEnforced()
7272
$"Expected the table.concat cap to fire, got: {ex.Message}");
7373
}
7474

75+
[Test]
76+
[Timeout(15000)]
77+
public void NestedGuardedCall_SameState_OuterBudgetStaysArmed()
78+
{
79+
LuaCsSecureEnvironment env = new();
80+
LuaCsApiRegistry registry = new();
81+
LuaCsExecutionGuard nestedGuard = new(timeoutMs: 2000, maxSteps: 10_000);
82+
LuaState state = null;
83+
LuaFunction noop = null;
84+
registry.Register("nested", new System.Func<double>(() =>
85+
{
86+
// Mirrors mods_call: a guarded call re-entering the guard on the SAME LuaState.
87+
LuaValue[] r = nestedGuard.Execute(state, noop, CancellationToken.None);
88+
return r.Length > 0 ? r[0].Read<double>() : 0d;
89+
}));
90+
state = env.Create(registry);
91+
noop = env.RunChunk(state, "return function() return 1 end")[0].Read<LuaFunction>();
92+
93+
// The nested guard's cleanup must restore the outer hook instead of clearing it; otherwise
94+
// the over-budget loop after nested() runs unlimited and the chunk returns normally.
95+
LuaCsExecutionGuard outerGuard = new(timeoutMs: 2000, maxSteps: 5_000);
96+
LuaRuntimeException ex = Assert.Throws<LuaRuntimeException>(() =>
97+
env.RunChunk(state,
98+
"nested()\n" +
99+
"local x = 0\n" +
100+
"for i = 1, 100000 do x = x + 1 end\n" +
101+
"return x",
102+
outerGuard));
103+
104+
Assert.IsTrue(ex.Message.Contains("EXCEEDED_HARD_LIMIT_STEPS"),
105+
$"Expected the OUTER step budget to stay armed after a nested guarded call, got: {ex.Message}");
106+
}
107+
75108
[Test]
76109
public void AllocationBomb_NormalHundredKbString_StillPasses()
77110
{

Assets/CoreAIMods/Tests/EditMode/LuaModHeaderEditModeTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,31 @@ public void Parse_CapabilitiesCommaList_RoundTripsViaLuaCapabilities()
7676
Assert.AreEqual("Read, Gameplay, WorldEdit", header.Capabilities);
7777
}
7878

79+
[Test]
80+
public void Parse_UnknownCapabilityToken_IsSkippedInsteadOfThrowing()
81+
{
82+
LuaModHeader header = LuaModHeader.Parse(@"--[[@coreai
83+
id: tolerant
84+
capabilities: Read, Superpowers, WorldEdit
85+
]]", "fallback");
86+
87+
Assert.AreEqual("tolerant", header.Id);
88+
Assert.AreEqual("Read, WorldEdit", header.Capabilities,
89+
"Unknown capability tokens must be skipped, keeping the known ones.");
90+
}
91+
92+
[Test]
93+
public void Parse_OnlyUnknownCapabilityTokens_FallsBackToNone()
94+
{
95+
LuaModHeader header = LuaModHeader.Parse(@"--[[@coreai
96+
id: tolerant
97+
capabilities: Superpowers
98+
]]", "fallback");
99+
100+
Assert.AreEqual("None", header.Capabilities,
101+
"A capabilities line with no known token must fail closed to None, not throw.");
102+
}
103+
79104
[Test]
80105
public void Parse_NoHeader_ReturnsFallbackIdAndDefaultActive()
81106
{

0 commit comments

Comments
 (0)