Skip to content

v0.4.14: who decides, and what was actually checked

Choose a tag to compare

@matAtWork matAtWork released this 13 Sep 20:29

What's Changed

  • PermissionGate — a privileged operation declares that acceptance is needed; a replaceable policy decides how it is obtained. 20 call sites, 13 gate ids, three copies of confirmAction gone (#62, #69)
  • @matatbread/matbot-default-gate (new) — the default policy each host seeds: ask, offer standing answers, honour what was remembered. Carries gate_action for inspecting and forgetting them
  • A one-off Allow no longer records a standing answer — the four options were matched by prefix, and Allow shares its first letter with Always allow …
  • A store's shape is read as TypeScript, not matched against a pattern — seven silent-emit paths closed, each of which produced a confident wrong document type (#64)
  • A checker that checks nothing says soToolCheckReport.checked, function-tools' definedUnchecked, and a shape that yields no document type refused rather than degraded
  • PluginSettings.entries() — a plugin can enumerate its own settings, in exactly one get
  • A select option can carry a value distinct from its labelFormField.options takes string | { value, label }
  • .compiled-plugins/ — the compiled-plugin build root is dot-prefixed like every other matbot-written root, and an installation can site it
  • docker-bash defaults to node:24-bookworm, plus a bash_config { action: 'pull' } that recreates the container and streams the pull
  • The CLI REPL is coloured by role, on a tty only (#66)

Full Changelog: v0.4.13...v0.4.14


⚠️ Breaking

Three, all of them named in CHANGELOG.md with the one-line fix:

  • default_settings.__matbot_core__.overwriteToolsOnCollision is gone in both forms — a stored answer and a configured floor — and is not migrated. Re-author it as default_settings.'@matatbread/matbot-default-gate'.'tools.overwrite', taking the same list of tool names (or true). An install still carrying the old key is warned at boot, naming where it went. The standing answer is otherwise re-offered the first time that collision comes round again, so restoring it is one click — adopt-once machinery would have kept a reserved namespace alive to save a keystroke.
  • ToolTypeIndex.check returns a ToolCheckReport, not string[].
  • ToolContext.gate is required, and PluginSettings.entries() with it — additive for callers, breaking for anyone who implements either shape (an embedder standing up its own tool-invocation door, a test fixture, a settings facade). bindGate(machine.PermissionGate, toolName, ask) supplies the first in one line.

And one rename that is not an API break but does need an edit: compiled-plugins/ is now .compiled-plugins/, not migrated. An install with compiled plugins renames the directory and the matching ./compiled-plugins/<tool> entries in its config together, or pins the old name through compiledPluginsDir. A stale entry fails to load, naming itself, at boot.


The call site declares; the policy decides

Installing a plugin, adding a provider profile, connecting an MCP server, overwriting a tool another plugin owns — each of these did two jobs at the call site: decide that acceptance was needed, and implement how it was obtained. The second had grown a long way past a prompt. Core held a settings key, a cached memo, a per-tool allowlist and two "always" options; confirmAction was implemented three times over, in tool-plugin, mcp-http and browser.

The consequence is the thing this release fixes: an alternative installation could not supply a policy, only defeat one.

A call site's whole contribution is now a PermissionRequest:

if (!await ctx.gate({ gate: 'add', subject: specifier, label: `Install plugin **"${specifier}"**?`, fallback: false })) {
  yield { type: 'result', value: { message: 'Cancelled.' } };
  return;
}
// → services.PermissionGate.decide({ gate: 'plugin.add', subject, label, fallback }, ask)

Four properties are worth knowing before you write a policy:

  • ask === undefined IS "no human is reachable." decide receives the prompt channel in scope for that call rather than holding one, because it is per-turn and frontend-owned. The runner passes the raw opts.prompt, never the stand-in that answers with a field's default — that substitute would make "nobody is here" indistinguishable from "a human answered with the default". ToolContext.prompt keeps the stand-in exactly as before.
  • ctx.gate takes the suffix; the host qualifies it with the tool's registered name. So one answer covers both runtimes' implementations of a tool — node's tool-plugin and the browser's plugin-tool both register plugin; mcp and mcp-http both register mcp_action — and a plugin cannot address a gate it does not own, tool-name collision on register closing the impersonation route.
  • subject is a field, never folded into gate. A composite id makes the vocabulary unbounded, which collides with "an unknown gate id must ask"; subjects contain the separator anyway (@scope/pkg, ./plugins/x, https://…/plugin.ts). It is also the identifier the call site has, not a canonical identity — at plugin.add the plugin is not loaded, so an allow for @x/foo deliberately does not match https://…/foo.ts.
  • PermissionGate is a swap-member, not an optional service. Absence is not a sensible state, so the host captures a boot default and unregister reverts to it: unload the policy ⇒ back to asking, with no revert rule of its own.

registry.ts sheds ~60 lines of policy for one decide({ gate: 'tools.overwrite', fallback: true }). That fallback: true is load-bearing: it is what still lets a deliberate override win at boot with nobody present, so the assertion recipe in docs/PER-USER-PLUGINS.md keeps working.

The 19 converted call sites are a new implementation with the same behaviour. Each still asks a human when there is one and still declines when there is not — every gate but tools.overwrite declares fallback: false, which is what the CONFIRM_NO default already resolved to. What changes is who answers, and that an installation can decide without editing a plugin.

mcp_action add is the one case to know about, being newly gated this release: connecting registers a remote party's tools for the rest of the session, and the local (stdio) variant spawns a child process on the host, yet only remove used to ask. A caller with no prompt channel (invokeTool, a trigger, a compiled skill, POST /tools/:name) now gets Cancelled. The supported fix is to name the gate, not fork the tool:

default_settings:
  '@matatbread/matbot-default-gate':
    'mcp_action.add': true          # or a list of server names

@matatbread/matbot-default-gate

A library each host seeds, in the tool-plugin mould rather than a configured plugin: createDefaultGate becomes the boot PermissionGate, and gate_action is registered beside plugin/provider. It has to be. A minimal install's first act is adding a plugin or a provider — which is gated — so neither the policy nor the means to inspect and undo an answer may depend on a plugins: line. Seeding it is also what stops gate_action colliding with itself at every boot.

It reproduces the old behaviour, keyed (gate, subject) in its own settings namespace:

default_settings:
  '@matatbread/matbot-default-gate':
    'tools.overwrite': [bash, plugin]

gate_action has get (the standing answers in effect — a stored answer and a configured floor read identically, because the question is whether the prompt appears) and clear (forget a gate, or one subject within it; clearing means revert to what the installation configured). There is deliberately no set: the write path for a runtime actor is answering a prompt that names the specific act.

get reports answers, not a vocabulary. A gate nobody has answered is absent from the listing rather than shown as "will ask" — an absent key says only that no answer was recorded, and what happens then is the call site's fallback and whatever policy is registered, neither of which this tool can know. A fresh install therefore lists nothing: everything is default behaviour.

Honest statement of what this buys

Not "the LLM cannot change this", but "a privileged operation is decided somewhere replaceable, and by default that means a human is asked."

The default policy keeps its answers in .data/settings/, which any shell tool can write — immediately, since reads are not cached. A standing answer is also a decision, not a channel: it applies at every door, including the ones with no human behind them, so Always allow every plugin.add is a blanket grant to anything that can reach POST /tools/:name — the model itself, through its own http or bash tool. That is why the per-subject form is offered first. And plainly: a gate that auto-approves plugin.add has granted everything, a loaded plugin having full Node capability with no in-process sandbox.

A deployment that needs a real boundary ships a gate with its rules compiled in. The levers are named for a policy author rather than papered over: decide sees ask === undefined for any caller with no human behind it, and runs under the ambient security principal (tryCurrentPrincipal()), so a stricter deployment refuses there in four lines. Neither is a field on PermissionRequest — a request describes the act; the context of the call is already in scope.

Two more things a policy author should be told rather than discover: the provider path chains two gates (add-unverifiedadd), so one user-visible operation can cost two decisions; and a collision raised while a replacement policy plugin is itself loading falls to the host's seeded default.

The Allow that granted forever

Found by testing the above. Clicking Allow once recorded a standing answer nobody gave: the four options were matched by prefix, and Allow shares its first letter with Always allow …. So the most ordinary answer a user can give silently granted permanent permission, and every later privileged act on that subject proceeded with no prompt at all.

Fixed twice over. The immediate fix is exact matching. The structural one is that the identity was the rendered label in the first place: FormField.options now takes string | { value, label }, a bare string still meaning "the value is the label". Frontends render optionLabel(o) and answer with optionValue(o), default names a value, and a caller branching on the answer compares a token rather than rewordable, localisable prose. confirm never had this problem, because CONFIRM_YES/CONFIRM_NO are tokens rather than labels; this is the same medicine for select. ask_user keeps bare strings — its options are the answer, and allowOther free text arrives on the same channel.

Deliberately not an index: free text has to be representable, a persisted form-response of "2" is unreadable and silently changes meaning if options are reordered, and cancellation already has PromptCancelledError rather than a sentinel.

Two CLI fixes ride along, neither frontend-dependent, since the CLI resolves everything itself: the select resolver matches a typed prefix against labels and returns the option's value, with an answer matching no option falling back to the default rather than being returned verbatim; and the abort-time form path passed a synthesised label+hint string to prompt(), which took the free-text branch — so a form's select answer came back as whatever was typed, never as the option it named.

PluginSettings.entries()

The same review thread turned up the reason the gate had a __gates__ index at all: PluginSettings was get/set/delete, with no way to ask what a namespace holds. An index beside the data is wrong in three ways at once — it cannot see a key the installation configured (which is in force, and therefore part of the answer), it drifts when the write and the index-write are interrupted, and it records what was ever written rather than what is set.

entries() returns everything in force as one map, layered exactly as get is: a stored key wins, else the configured default, and a stored null is still an override. It costs exactly one get — a settings namespace is one document, so on the media we define that is one readFile / one primary-key row / one keyed IndexedDB get / one Drive fetch. Which is also why there is no keys(): that plus N × get would be N+1 reads of the document entries() returns in one. (The gate's own listing was doing precisely that N+1.)

Adding a method affects implementations, not consumers: there is one (makePluginSettings, which the browser host reuses) plus the node MCP plugin's prefix-scoping wrapper, which now enumerates its own half of a shared document.

A contract read as code, and a checker that admits it checked nothing

The other half of the release is the tool-typing path, and its theme is the same one stated everywhere else in it: a validator that reports success while checking nothing is indistinguishable from one that works.

A tool-store store's shape is model-authored TypeScript, and it was being matched against an end-anchored regular expression. Seven distinct paths through that produced a confident, wrong document type and reported no fault:

  • a comment inside the declaration survived into the toolContract, which is emitted as one line — so // the note body commented out every arm after it and the whole contract became unparseable;
  • a comment or blank line after the declaration missed the }\s*$ anchor and the document type degraded silently to Record<string, unknown>;
  • an unterminated quote (an apostrophe in prose — Note's fields:) made the rest of the shape inert, which had re-opened the comment leak above;
  • extends Base<{ … }> handed back the base's type argument as the document body;
  • => in a function type counted as a closing angle bracket, truncating an alias mid-member and emitting it unbalanced;
  • a second declaration was ignored with the first winning, so type Id = string; type Note = { id: Id } emitted string — the helper type as the document;
  • an unterminated alias read to end of input, so trailing prose became part of the type ({ text: string } Stored per user.) — accepted at create, unparseable by the time anything downstream read it.

Comments are now stripped with string literals intact, the body is brace-matched, the brace is found by scanning at angle-depth 0, and an alias's end is found by scanning the type expression. The shape's name is read by the same path — its old regex (type\s*=\s+(\w+)) never matched a type alias at all, so an aliased shape was described as Store<Record<string, unknown>> in prose. And a shape that yields no document type is now refused at create/expose, naming which of four faults it hit, rather than degrading to a type that validates anything. An already-persisted definition warns instead, so an existing store never loses its tool at boot.

Four more tool-store fixes land with it: a store tool declares one contract arm per action, so a call narrows its result (it emitted one arm carrying two unions, which ToolProxy turns into a single call signature with nothing to overload — and since a cast is barred by the check gate, a caller could not write correct code against a store tool at all); set accepts and ignores id/version, so the natural get → edit → set round-trip works instead of every update being rebuilt field by field into a replace that silently dropped what the rebuild forgot; an id inside data must agree with the key on set and cas, refused rather than resolved, because discarding it writes the edit to a document the caller did not name and honouring it writes to one they did not name either; and set is now described as create-or-replace, not "upsert" — the executor never read the existing document, so a partial set deleted every field it omitted.

A store may also hold several kinds of document: write the shape as a discriminated union and each arm keeps its own fields. That follows from the per-action arms above, and is now stated in store_action's description, which said nothing about it.

Above that, the report itself became data. ToolTypeIndex.check returns a ToolCheckReport rather than string[]: it used to return formatted multi-line blocks with the overflow summary appended as a further element, so diagnostics.length counted prose as a finding and any per-code breakdown was unreliable wherever an overflow had occurred. The report states total separately from the capped diagnostics and puts the cap in omitted: { count, byLabel }; each finding carries its own rendered block, so a consumer displays that and counts, groups or routes on the fields rather than regexing line \d+ TS\d+ back out of prose. The typed record existed upstream all along and was discarded at render time.

It carries a required checked: boolean that qualifies ok. An index that cannot type-check — the browser, which has no TypeScript program — reports ok: true, checked: false: nothing was examined, rather than nothing was wrong. function-tools was recording such a definition as verified, and now marks it definedUnchecked in list and on each check row. define said "(type-check skipped)" once, in the moment, and nothing afterwards knew — so a noTypeCheck definition was indistinguishable from one that passed, and the errors it hid surfaced much later as failures that had been latent all along. Both bypass routes set it, and a later passing check deliberately does not clear it: it records the provenance of the definition.

Finally, a cast-gate finding is labelled CAST-GATE wherever it is rendered. The detail renderer honoured the synthetic flag and the overflow summary did not, so one rule had two names decided only by position in the list — and the second, TS90003, is not a tsc error code at all, so a reader who looked it up concluded the compiler had no such code.

.compiled-plugins/, sited by the installation

The compiled-plugin build root is renamed from compiled-plugins/. Every other matbot-written, gitignored root beside matbot.yaml is dot-prefixed (.data/, .plugins/, .env); this one was the outlier.

It stays out of .data/ for a second reason now recorded beside the durability one (a compiled plugin has no upstream, so a cache clear would lose it): docker-bash mounts the project root read-only and then .data read-write over it, so a build dir under there would be writable by the model from inside the container — and a loaded plugin is full Node capability with no sandbox, which is precisely what plugin.add's gate exists to decide.

An installation can site the directory via compiledPluginsDir, for a deployment running matbot per user (separate pods, a read-only project root, a per-user volume):

default_settings:
  '@matatbread/matbot-tool-skill-compiler':
    compiledPluginsDir: .compiled-plugins

There is deliberately no action to change it at runtime, and the reason is the migration hazard: plugin add records ./<dir>/<tool> in the config, so every already-compiled tool's entry is spelled with the name and a change orphans all of them at once. An installation answering the question at boot is a different act from a running machine moving the goalposts.

Other fixes

  • A tool collision resolved with nobody to ask says so again. The pre-gate code warned when it overwrote non-interactively; routing the decision through a gate dropped the line, leaving the one decision here that proceeds with no human and no record of itself.
  • A plugin's services.PermissionGate is the live policy, not a copy taken when it loaded. The per-plugin machine is built with { ...services }, which evaluates the host's getters once — fine for the swap-members that hand back capture-safe proxies, and not for the one that deliberately does not. A policy registered after a plugin loaded was never reached through that machine (frontend-web's POST /tools/:name route reads exactly this way), and unloading one left the copy on the gone impl.
  • A policy that displaces another can delegate to it. PermissionGate was behind the same capture-safe forwardingProxy as the other swap-members, so const previous = services.PermissionGate captured a reference resolving to whatever is current — the capturing gate, one line later. It called itself until the stack overflowed, so the composition the docs recommend was the one thing that could not work. (ToolCallValidator composes identically and was never proxied, which is why that idiom worked.) Both hosts expose the member as a getter.
  • Two "Always allow" answers at once no longer drop one. Appending a subject is a read-modify-write and PluginSettings has no CAS on a value, so both reads saw the same list. The policy serialises its writes and re-reads inside the queue. Cross-process stays last-write-wins — the store's contract, not this policy's.
  • gate_action clear no longer reports a configured default as cleared. delete reverts to what the installation configured, so for a gate answered only in default_settings: it is a no-op — and naming it under cleared was a success report for nothing. What changed is reported as forgotten; what is still in force is named separately as coming from config.
  • The telegram frontend supplies no PromptFn rather than a stub answering with each field's default. The stub was the worse lie: to a tool it resolved silently, the model proceeding as if answered while the chat saw no question; to a gate it looked like a reachable human, "no PromptFn at all" being the single signal for nobody is here.
  • plugin / matbot install: an npm install into a pnpm workspace root now succeeds. Both shelled out to pnpm add <pkg> in the directory holding matbot.yaml; at a workspace root pnpm refuses outright (ERR_PNPM_ADDING_TO_ROOT) and the install failed naming a -w flag the user had no way to pass. The root is now stated explicitly when a pnpm-workspace.yaml is present. Other package managers are unaffected.
  • The plugins/ dts scan skips dot-directories rather than naming compiled-plugins — a second spelling of a constant skills_compiler owns, now relocatable per installation and therefore unknowable there.
  • tool-router drops the unpopulated derivedHidden set — declared and read by three filters but never added to, so it changed no behaviour while presenting an extension point that looked wired.
  • core re-exports CONFIRM_YES / CONFIRM_NO. It already re-exports every other cross-boundary runtime value so an app needs no direct plugin-api dependency; these were missed, and an app implementing a PromptFn is exactly who needs them.

Also in this release

  • docker-bash defaults to node:24-bookworm instead of ubuntu:24.04, so Node 24 and npm are preinstalled and a script no longer has to apt-get a toolchain before it can run anything JavaScript. Still Debian, so apt remains the package manager and existing scripts are unaffected. An existing matbot-bash container keeps whatever image it was created from — the container is only created when absent — so adopting the new default is one bash_config { action: 'pull' }. That action fetches the configured image and recreates the container unconditionally (an image already up to date says nothing about which image the existing container was built from, which is exactly the case after this default moves), streaming the pull's progress as it goes — dockerExec buffers, and a ~1GB first pull is otherwise silence indistinguishable from a hang.
  • The CLI REPL is coloured by role, on a tty only (#66): user input white, assistant cyan, harness chatter (prompts, thinking, tools, markers, accounting, diagnostics) yellow. Two tty flags, one per stream — the assistant's text is the only thing on stdout, so redirecting it stays clean while stderr may still be an interactive terminal.

Testing

440 tests pass; the workspace typechecks; check:ui, check:contracts and check:isolation are clean; both web bundles are rebuilt.

New coverage worth naming: apps/cli/test/permission-gate.test.ts pins the host boot default (ask / no-ask ⇒ fallback), the suffix→tool-name binding, unknown-gate ⇒ ask, delegation to a displaced gate in either load order, and gate_action get/clear including "clear cannot delete the configured floor". The Allow regression is pinned twice, both verified failing against the old code: a plain Allow persists nothing and re-asks, and echoing a rendered Always allow … label stores nothing either. entries() is pinned directly — stored-over-floor per key, a stored null as an override, delete reverting to the floor. The two tool-collision tests now drive the policy rather than core's retired settings key, picking an option by label and answering with its value, the way a frontend does.

Verified on matbot.minimal.yaml with an empty plugins:: ['about_matbot', 'gate_action', 'plugin', 'provider', 'single_turn'].