Plugin Access Control
Every host function your plugin calls is checked against the policies in your
plugin.yaml. No policy, no access. This guide is a set of recipes: each
section grants one capability. Add the policies your plugin needs and skip the
rest.
For the full DSL reference, operator guide, and capability tables, see the Access Control Reference.
React to events without any policy
Section titled “React to events without any policy”A plugin that only reacts to events and returns new events needs no policies.
The event delivery system handles subscriptions from the events list in your
manifest; returning events from on_event goes through that system, not a host
function:
name: echo-botversion: 1.0.0type: luaevents: - saylua-plugin: entry: main.luafunction on_event(event) if event.type ~= "say" or event.actor_kind == "plugin" then return nil end local msg = event.payload:match('"message":"([^"]*)"') if not msg then return nil end return { { stream = event.stream, type = "say", payload = '{"message":"Echo: ' .. msg .. '"}' } }endTo emit events outside of a direct on_event response, you need an emit policy
(next recipe).
Grant proactive event emission
Section titled “Grant proactive event emission”To emit events proactively (not just as a return value from on_event), add an
emit policy:
name: announcerversion: 1.0.0type: luaevents: - systempolicies: - name: "emit-events" dsl: | permit(principal is plugin, action in ["emit"], resource is stream) when { principal.plugin.name == "announcer" };lua-plugin: entry: main.luaThe principal.plugin.name condition scopes the permit to just this plugin —
other plugins can’t piggyback on your policy.
Grant world reads
Section titled “Grant world reads”To read game-world state — who’s in a location, character names, objects — add a read policy scoped to the resource patterns you need:
name: greeterversion: 1.0.0type: luaevents: - arrivepolicies: - name: "read-characters" dsl: | permit(principal is plugin, action in ["read"], resource is world_object) when { principal.plugin.name == "greeter" && resource like "character:*" }; - name: "emit-events" dsl: | permit(principal is plugin, action in ["emit"], resource is stream) when { principal.plugin.name == "greeter" };lua-plugin: entry: main.luaThe resource like "character:*" pattern grants character reads but not
locations or objects — grant exactly what you need. To also read locations,
widen the pattern:
- name: "read-world" dsl: | permit(principal is plugin, action in ["read"], resource is world_object) when { principal.plugin.name == "greeter" && (resource like "character:*" || resource like "location:*") };Grant key-value storage
Section titled “Grant key-value storage”To persist data between events (scores, settings, cooldowns), grant access to the key-value store:
name: dice-trackerversion: 1.0.0type: luaevents: - saypolicies: - name: "emit-events" dsl: | permit(principal is plugin, action in ["emit"], resource is stream) when { principal.plugin.name == "dice-tracker" }; - name: "kv-storage" dsl: | permit(principal is plugin, action in ["read", "write"], resource is kv) when { principal.plugin.name == "dice-tracker" };lua-plugin: entry: main.luaThis grants read and write but not delete. To grant all three, add "delete"
to the action list:
permit(principal is plugin, action in ["read", "write", "delete"], resource is kv) when {Combine policies for a full plugin
Section titled “Combine policies for a full plugin”A full-featured plugin declares one policy per capability. A combat system that emits events, reads world state, stores data, and registers commands declares all four:
name: combat-systemversion: 1.0.0type: binaryevents: - say - arrive - leavepolicies: - name: "emit-combat-events" dsl: | permit(principal is plugin, action in ["emit"], resource is stream) when { principal.plugin.name == "combat-system" };
- name: "read-world-state" dsl: | permit(principal is plugin, action in ["read"], resource is world_object) when { principal.plugin.name == "combat-system" && (resource like "character:*" || resource like "location:*" || resource like "object:*") };
- name: "combat-storage" dsl: | permit(principal is plugin, action in ["read", "write", "delete"], resource is kv) when { principal.plugin.name == "combat-system" };
- name: "register-commands" dsl: | permit(principal is plugin, action in ["execute"], resource is command) when { principal.plugin.name == "combat-system" };binary-plugin: executable: combat-systemAn operator reviewing this manifest sees exactly what the plugin needs — emit to any stream, read characters/locations/objects, read/write/delete storage, and register its own commands — and decides whether to trust it.
Handle access denials
Section titled “Handle access denials”If your plugin tries something without a matching policy, the host function
returns "access denied". Branch on it:
local location, err = holomush.query_location(location_id)if err then if err:match("access denied") then -- Missing policy — check your plugin.yaml holomush.log("warn", "No policy for location query") return nil end -- Some other error holomush.log("error", "Query failed: " .. err) return nilendThe denial is logged server-side too, so operators can see what happened.
Debugging tips
Section titled “Debugging tips”- Check server logs. Denied actions are logged with your plugin name, the action, and the resource.
- Match your plugin name exactly. The
principal.plugin.namein your policy must match thenamein your manifest. Case matters. - Check your resource patterns.
"location:*"won’t match"character:123". Make sure yourlikepatterns cover what you’re accessing. - Start broad, narrow later. While developing, you can use broad
patterns like
resource like "*", then tighten them before release.
Further reading
Section titled “Further reading”- Access Control Reference — Full DSL spec, operator guide, capability tables
- Plugin Guide — Complete plugin development guide
- Getting Started — Your first plugin