security: engine-call natives derive the plugin id from the calling context (#50)
* security: engine-call natives derive the plugin id from the calling context
The four __s2_engine_call_* natives took the plugin id as JS argument 0 and
keyed gamedata_calls authorization on it, while sitting on every plugin's
global object.
gamedata_calls::prepare gates the engine:calls permission ONCE, at registration
(gamedata_calls.rs:257), so a descriptor's presence in the registry IS its
authorization. That made the caller-supplied id the thing deciding who may
drive an operator-allow-listed engine call: any loaded plugin could invoke the
calls allow-listed for a DIFFERENT plugin by passing its id as a string,
rendering configs/permissions.json decorative for this permission.
All four now read current_plugin(scope) — the context-slot identity 58 other
natives already use. A context with no plugin identity fails closed to the
existing default (false / "unavailable" / null). The @s2script/sdk/unsafe
prelude stops passing pid at all, so it cannot name another plugin even by
mistake (Engine.status already used __s2_current_plugin; the other three did
not).
Not a breaking change: Engine.call(name) / Engine.status(name) keep their
signatures and behavior. Only the internal __s2_* arity moved — no .d.ts
change, no packages/* change, no changeset.
Test written first and watched fail, leaking the victim's degrade reason
("engine op unavailable") to the attacker's context. cargo test -p
s2script-core: 464 passed. CI=1 make ci: green.
* test: cover the legitimate engine-call path, not just the denial
Nothing in the suite covered a SUCCESSFUL Engine.call: with no
engine_call_resolve op under test, no descriptor ever reaches Ready, so the
happy path was invisible to CI both before and after the arity change.
Adds fake resolve/invoke ops and asserts an authorized plugin's own declared
call still yields a working callable and lands its declared float arg in the
right slot. Uses a receiverless descriptor so no live entity is needed — the
arg array is the slot that moved (args.get(4) -> args.get(3)).
Verified by mutation: restoring args.get(4) fails this test with left: NaN,
right: 2.5. G
Gabriel Hirakawa committed
c032e32d9ea28ef2b050ed810a2901177ff32c21
Parent: 461b583
Committed by GitHub <noreply@github.com>
on 8/1/2026, 5:50:49 AM