Plugin Access Control¶
Every host function your plugin calls is checked against the policies in your
plugin.yaml. No policy, no access. This page walks through writing policies
from the simplest case to more complex scenarios.
For the full DSL reference, operators guide, and capability tables, see the Access Control Reference.
The Simplest Plugin: No Policies Needed¶
A plugin that only reacts to events and returns new events doesn't need any
policies at all. The event delivery system handles subscriptions based on
the events list in your manifest:
function 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 .. '"}'
}
}
end
This works because returning events from on_event goes through the event
delivery system, not a host function. No policy needed.
But wait — this plugin is emitting events by returning them. What if you want to emit events outside of a direct response? Then you need a policy.
Adding Event Emission¶
If your plugin needs to emit events proactively (not just as a return value
from on_event), add an emit policy:
name: announcer
version: 1.0.0
type: lua
events:
- system
policies:
- name: "emit-events"
dsl: |
permit(principal is plugin, action in ["emit"], resource is stream) when {
principal.plugin.name == "announcer"
};
lua-plugin:
entry: main.lua
The policy says: "The plugin named announcer is allowed to emit events to
any stream." The principal.plugin.name condition scopes it to just this
plugin — other plugins can't piggyback on your policy.
Reading the World¶
A plugin that needs to know about the game world — checking who's in a location, looking up character names, examining objects — needs read policies.
Here's a greeter that welcomes players by name when they arrive:
name: greeter
version: 1.0.0
type: lua
events:
- arrive
policies:
- 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.lua
Notice the resource like "character:*" pattern — this plugin can read
character data but not locations or objects. You grant exactly what you need.
If the greeter also needed to check what location someone arrived in:
- 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:*")
};
Using Key-Value Storage¶
Plugins that need to persist data between events (scores, settings, cooldowns) use the key-value store. Access requires its own policy:
name: dice-tracker
version: 1.0.0
type: lua
events:
- say
policies:
- 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.lua
This grants read and write but not delete. If you need all three:
A Complex Plugin: Combat System¶
A full-featured plugin might need multiple policies. Here's what a combat system might declare:
name: combat-system
version: 1.0.0
type: binary
events:
- say
- arrive
- leave
policies:
- 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-system
This plugin can:
- Emit events to any stream (combat results, damage, effects)
- Read characters, locations, and objects (check stats, range, inventory)
- Store and retrieve data (HP, buffs, cooldowns)
- Register its own commands (attack, defend, flee)
An operator reviewing this manifest can see exactly what the combat system needs — and decide whether to trust it.
When Access Is Denied¶
If your plugin tries something without a matching policy, the host function
returns "access denied":
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 nil
end
The denial is logged server-side too, so operators can see what happened.
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¶
- Access Control Reference — Full DSL spec, operator guide, capability tables
- Plugin Guide — Complete plugin development guide
- Getting Started — Your first plugin