@matatbread/matbot-default-gate
v0.4.14
Published
matbot's default permission policy, seeded by the host rather than loaded as a plugin: asks about a privileged operation, offers to remember the answer, honours what was remembered — plus the built-in gate_action tool for inspecting and forgetting standin
Downloads
144
Maintainers
Readme
@matatbread/matbot-default-gate
matbot's default permission policy.
A privileged operation — installing a plugin, adding a provider profile, removing an MCP server,
overwriting a tool another plugin owns — declares that acceptance is needed, by calling
ctx.gate({ gate, subject, label, fallback }). What happens then is this package's business, and
it is replaceable: an installation that wants different rules registers its own PermissionGate
instead of undoing this one.
This is a library, not a plugin — do not list it in plugins:. Each host seeds it at boot
(createDefaultGate as the PermissionGate, createGateTools() beside the plugin and provider
builtins), because a minimal install's first act is a gated one: making the policy, or the means to
inspect and undo an answer, conditional on a config line answers the question at the wrong moment.
This one reproduces matbot's historical behaviour:
- ask the user, through whatever channel the turn has;
- offer two standing answers — Always allow "" and Always allow every
<gate>; - honour what was remembered, silently, next time;
- with nobody to ask, answer the request's own
fallback(which is what the call site says its non-interactive behaviour is —truefor a tool-name collision at boot,falseeverywhere else).
Configuring it
Standing answers live in this package's own settings namespace, one key per gate id, so an installation authors them the ordinary way (the hosts exempt this one key from the "names no loaded plugin" warning, since nothing loads it):
default_settings:
'@matatbread/matbot-default-gate':
'tools.overwrite': [bash, plugin] # subjects allowed without asking
'plugin.add': true # every subject allowed — see the warning belowA value is true (allow every subject), or a list of subjects to allow. Anything else asks.
This is also the supported way to make a gated operation work for callers that have no prompt
channel — invokeTool, a trigger, a compiled skill, POST /tools/:name — which otherwise get the
call site's fallback (a refusal, for everything but tools.overwrite). Connecting an MCP server
from a script is the usual case:
default_settings:
'@matatbread/matbot-default-gate':
'mcp_action.add': trueNaming the gate is the whole point of the seam: the alternative is editing the plugin that declares it, and an installation should not have to fork a tool to decide its own policy.
subject is the identifier the call site has, not a canonical identity. At plugin.add the
plugin is not loaded yet, so the subject is the specifier as typed: an allow for @x/foo does not
match https://…/foo.ts. That is correct — different trust root, different decision — but it means a
policy keys on the spelling.
A gate that auto-approves plugin.add has granted everything. A loaded plugin has full Node
capability and there is no in-process sandbox. And a standing answer is a decision, not a channel:
it applies at every door, including POST /tools/:name, invokeTool and a trigger — so Always allow
every plugin.add lets anything that can reach the HTTP endpoint install anything, with nobody asked.
Prefer the per-subject form.
A policy can refuse where nobody is watching. decide(req, ask) gets ask === undefined when no
human is reachable, so a policy that wants standing answers to apply only to interactive callers is
four lines — if (ask === undefined) return req.fallback; before consulting them. It also runs under
the ambient security principal, so tryCurrentPrincipal() says who is asking with no extra plumbing.
Neither is what the shipped policy does: an installation that configures 'mcp_action.add': true
precisely wants the non-interactive caller to succeed, so the default honours an answer wherever the
operation happens. Write the stricter one if your deployment wants it.
These answers are not out of the model's reach. They live in .data/settings/, which bash can
write and docker-bash mounts read-write, so on an install with a shell tool the model can author its
own standing answer and every later prompt is skipped. This policy is therefore a record of decisions,
not a security boundary; a deployment that needs one ships a gate with its rules compiled in — which is
what the replaceable seam is for.
gate_action
{ action: 'get' }— the standing answers in effect (optionally onegate). A stored answer and a configured default read identically, because the question is whether the prompt appears. A gate with no answer is not reported: on a fresh install this returns nothing, and every gate simply behaves as configured. There is no list of "all the gates" — ids are open, and an absent key says only that nobody answered, not what will happen. Name agateto ask about one, which is also the only way to see one an installation configured but nobody has answered (settings cannot enumerate their keys).{ action: 'clear' }— forget standing answers so the operation asks again: everything, onegate, or onesubjectwithin a gate. Clearing means revert to the configured default, so an installation's owndefault_settings:floor survives it.
There is deliberately no set: the write path for a runtime actor is answering a prompt that names
the specific act.
Gate ids
tools.overwrite (subject: tool name) · plugin.add / plugin.provision-deps / plugin.remove /
plugin.npm-uninstall / plugin.load · provider.add / provider.add-unverified /
provider.update / provider.update-unverified / provider.remove · mcp_action.add /
mcp_action.remove.
A tool's ids are qualified with the name it is registered under, so one answer covers both runtimes' implementations of a tool. The list is open — a plugin this build never compiled against contributes ids, and an id this policy does not recognise is asked about, never allowed.
Two gates chain on the provider path (add-unverified → add, update-unverified → update): one
user-visible operation can cost two decisions, the first carrying information the second does not.
