Permission-gated step bridge for sandboxed mods

The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).

Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.

RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Myles Resnick
2026-08-13 11:19:06 -04:00
parent c0f654f8c2
commit bde606f966
8 changed files with 349 additions and 4 deletions
+27 -1
View File
@@ -20,6 +20,7 @@ local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Loader = {}
Loader.__index = Loader
@@ -251,7 +252,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {}, modEnv = {},
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -1009,6 +1010,30 @@ function Loader:_api(mod)
return state, percent
end,
},
-- The native step bridge (#1186), behind the "steps" permission the
-- player sees in the mod manager: sync asks the platform to refresh
-- its count, poll hands this mod its copy of what the bridge
-- delivered. The engine owns the pending file -- a mod never names a
-- path, it only receives { steps, from, to }. available() answers
-- false without the permission (a probe stays quiet); the calls that
-- would do something name the missing permission instead, the way the
-- network gate does.
steps = (function()
if mod.manifest.permissionSet.steps then
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
return {
available = function() return Steps.available() end,
sync = function() return Steps.sync() end,
poll = function() return Steps.poll(loader, modId) end,
}
end
local function refuse()
error(('[%s] mod.steps needs the "steps" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -1226,6 +1251,7 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
end
-- a mod that explicitly swears it stays link-compatible while writing into a
+2 -1
View File
@@ -10,7 +10,8 @@ local Version = require("src.core.Version")
local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
+2 -1
View File
@@ -69,7 +69,8 @@ end
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage and mod:read", thread = true,
system = "mod.device:powerInfo() for battery information", event = true,
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
local loveProxy
+84
View File
@@ -0,0 +1,84 @@
-- The scoped seam for the native step bridge (#1186).
--
-- The iOS/Android builds count the player's real-world steps natively
-- (#452, #489) and deliver them by writing steps_pending.json into the
-- save-directory root. Before the sandbox, the Pokéwalker mod called
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
-- blocks both, which is correct -- love.system launches URLs and the file
-- API names paths -- but it left the bridge with no consumer at all.
--
-- This module is the narrow replacement, gated by the "steps" permission
-- in manifest.json (the network model: a permission the player sees that
-- genuinely gates a capability). The engine owns the file: mods never
-- learn its name or location, they receive only the three contract fields
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
-- merge-don't-overwrite anchor semantics stay on the native side where
-- they always lived.
--
-- No frame pump: the file is looked for lazily when a mod polls, so a
-- build with no permissioned mod installed never touches the bridge or
-- the disk.
local Json = require("src.link.Json")
local Steps = {}
-- The native contract's drop point, in the save-directory root (see
-- mobile/ios and mobile/android step bridges).
Steps.PENDING = "steps_pending.json"
local function bridge()
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
end
-- Whether this build carries the native bridge. Desktop builds do not;
-- a mod uses this to stay dormant without probing love.system.
function Steps.available()
return bridge() ~= nil
end
-- Ask the native side to refresh its count. Async: the result lands in
-- the pending file and comes back through a later poll. The platform's
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
-- first use, exactly as it did pre-sandbox. false when there is no
-- bridge to ask.
function Steps.sync()
local fn = bridge()
if not fn then return false end
fn()
return true
end
-- Consume the pending file, if one has appeared, and fan its payload out
-- to every permissioned mod's queue. Only the contract fields travel;
-- anything else in the file stays in the file's grave. A malformed or
-- empty delivery is dropped whole -- the native anchor only advances on a
-- successful sync, so nothing is lost to a bad write.
function Steps.pump(loader)
local fs = _G.love and _G.love.filesystem
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
local raw = fs.read(Steps.PENDING)
fs.remove(Steps.PENDING)
if not raw then return end
local ok, decoded = pcall(Json.decode, raw)
if not ok or type(decoded) ~= "table" then return end
local steps = tonumber(decoded.steps)
if not steps or steps <= 0 then return end
local payload = { steps = steps, from = decoded.from, to = decoded.to }
for _, queue in pairs(loader.stepsQueues) do
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
to = payload.to }
end
end
-- The next delivery for this mod, or nil. Each permissioned mod consumes
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
-- read the file first won).
function Steps.poll(loader, modId)
Steps.pump(loader)
local queue = loader.stepsQueues[modId]
if not queue then return nil end
return table.remove(queue, 1)
end
return Steps