@matatbread/matbot-plugin-api 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.19",
7
+ "version": "0.4.21",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -0,0 +1,44 @@
1
+ // The `ToolContracts` arms for tools that carry their contract as a `toolContract` STRING, written out for a
2
+ // file that declares them: the generated dts a body is checked against, the program that types them for
3
+ // validation, a turn's own tools appended to a snippet. One implementation, because each of those must agree
4
+ // on what a string means, and a fourth copy of the namespace rule would drift from the other three.
5
+ //
6
+ // A string may name types that exist nowhere else — a function package's own `interface HubReport`, carried
7
+ // on `toolContractTypes`. Those are declared in a namespace of their own with an alias per tool beside them,
8
+ // so the contract is written exactly as its author wrote it and still resolves, and two packages' `Report`s
9
+ // never meet. Tools carrying identical declarations (one package's exports) share one namespace.
10
+
11
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
12
+
13
+ export interface ContractSource {
14
+ readonly name: string;
15
+ readonly contract: string;
16
+ readonly types?: string | undefined;
17
+ }
18
+
19
+ export interface ContractArms {
20
+ /** Top-level declarations to place ahead of the `ToolContracts` augmentation; empty when nothing has types. */
21
+ readonly preamble: string;
22
+ /** Per tool, the type to write as its `ToolContracts` arm: its own string, or the alias that wraps it. */
23
+ readonly arms: ReadonlyMap<string, string>;
24
+ /** Per namespace in the preamble, the tools it serves — what a diagnostic inside it is attributed to. */
25
+ readonly namespaces: ReadonlyMap<string, readonly string[]>;
26
+ }
27
+
28
+ export function contractArms(tools: Iterable<ContractSource>): ContractArms {
29
+ const arms = new Map<string, string>();
30
+ const groups = new Map<string, { ns: string; members: [alias: string, tool: string, contract: string][] }>();
31
+ for (const t of tools) {
32
+ if (t.types === undefined || t.types.trim() === '') { arms.set(t.name, t.contract); continue; }
33
+ let group = groups.get(t.types);
34
+ if (group === undefined) { group = { ns: `ToolContractTypes${groups.size + 1}`, members: [] }; groups.set(t.types, group); }
35
+ const alias = IDENTIFIER.test(t.name) ? t.name : `Tool${group.members.length + 1}`;
36
+ group.members.push([alias, t.name, t.contract]);
37
+ arms.set(t.name, `${group.ns}.${alias}`);
38
+ }
39
+ const preamble = [...groups].map(([types, g]) =>
40
+ `declare namespace ${g.ns} {\n${types.split('\n').map(l => ` ${l}`).join('\n')}\n`
41
+ + g.members.map(([alias, , contract]) => ` export type ${alias} = ${contract};`).join('\n') + '\n}\n').join('');
42
+ const namespaces = new Map([...groups.values()].map(g => [g.ns, g.members.map(([, tool]) => tool)] as const));
43
+ return { preamble, arms, namespaces };
44
+ }
@@ -0,0 +1,58 @@
1
+ // `fetch`, with every URL a request reaches — the one asked for and each one a redirect leads to — put to
2
+ // `admit` before anything is sent there. A gate on the first URL alone is a gate on nothing: an approved
3
+ // host that answers 302 hands the request, and whatever it carries, to an origin nobody was asked about.
4
+ //
5
+ // So a `follow` request (the default) is followed here, one hop at a time, as the platform would: 303, and
6
+ // 301/302 after a POST, become a bodiless GET; 307/308 resend the body; credentials are dropped on a change
7
+ // of origin; twenty hops at most. What is lost is cosmetic: the final `Response` reports the last hop's URL
8
+ // and `redirected: false`. A `manual` or `error` request is one hop, admitted and handed to `fetch` as is.
9
+ //
10
+ // A browser's `manual` redirect is opaque, with no Location to read, so there it is refused rather than
11
+ // followed blind.
12
+
13
+ const MAX_REDIRECTS = 20;
14
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
15
+ const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location'];
16
+ const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization'];
17
+
18
+ export async function originGatedFetch(
19
+ admit: (url: URL) => Promise<void>,
20
+ input: RequestInfo | URL,
21
+ init?: RequestInit,
22
+ fetchImpl: typeof fetch = fetch,
23
+ ): Promise<Response> {
24
+ const first = new Request(input, init);
25
+ await admit(new URL(first.url));
26
+ if (first.redirect !== 'follow') return fetchImpl(first);
27
+
28
+ // Read once, up front: a Request body is a stream, and a 307 or 308 sends it again.
29
+ const payload = first.body === null ? null : await first.clone().arrayBuffer();
30
+ let current = first;
31
+ let res = await fetchImpl(first, { redirect: 'manual' });
32
+ for (let hops = 0; ; hops++) {
33
+ if (res.type === 'opaqueredirect') {
34
+ throw new TypeError(`A redirect from ${current.url} was not followed: this platform does not say where it leads. Request the destination directly.`);
35
+ }
36
+ const location = res.headers.get('location');
37
+ if (!REDIRECTS.has(res.status) || location === null) return res;
38
+ if (hops >= MAX_REDIRECTS) throw new TypeError(`Too many redirects from ${first.url}.`);
39
+ await res.body?.cancel();
40
+
41
+ const next = new URL(location, current.url);
42
+ const toGet = (res.status === 303 && current.method !== 'HEAD')
43
+ || ((res.status === 301 || res.status === 302) && current.method === 'POST');
44
+ const headers = new Headers(current.headers);
45
+ if (toGet) for (const h of BODY_HEADERS) headers.delete(h);
46
+ if (next.origin !== new URL(current.url).origin) for (const h of CREDENTIAL_HEADERS) headers.delete(h);
47
+
48
+ await admit(next);
49
+ current = new Request(next, {
50
+ method: toGet ? 'GET' : current.method,
51
+ headers,
52
+ body: toGet ? null : payload,
53
+ signal: first.signal,
54
+ redirect: 'manual',
55
+ });
56
+ res = await fetchImpl(current);
57
+ }
58
+ }
package/src/hooks.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type {
2
2
  Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen, TurnToolSource,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
+ ValidateContext, ToolInputError,
4
5
  } from './types.js';
5
6
  import { foldOntoUserTurn, createMessage } from './session.js';
6
7
  import { withUsageSite } from './usage-context.js';
7
8
 
8
9
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
9
10
 
11
+ // A turn tool is never registered, so `register` never stamps its plugin — and that stamp is what a
12
+ // `ctx.gate` namespace is checked against. The hook that offered it IS stamped, so its tools take that,
13
+ // and a `pluginName` the source wrote itself is dropped: the claim would otherwise be the plugin's own word.
14
+ const ownedBy = (source: TurnToolSource, owner: string | undefined): TurnToolSource => async session =>
15
+ (await source(session)).map(({ pluginName: _, ...tool }) => owner !== undefined ? { ...tool, pluginName: owner } : tool);
16
+
10
17
  export class HookRegistry implements HookRegistrar {
11
18
  private readonly hooks = new Map<HookPoint, Hook[]>();
12
19
 
@@ -156,7 +163,7 @@ export class HookRegistry implements HookRegistrar {
156
163
  if (r.durable && r.durable.length) foldDurable(r.durable);
157
164
  if (r.markers && r.markers.length) appendMarkers(r.markers);
158
165
  if (r.deferred) deferred.push(r.deferred);
159
- if (r.tools) tools.push(r.tools);
166
+ if (r.tools) tools.push(ownedBy(r.tools, hook.pluginName));
160
167
  if (r.abort) {
161
168
  const drained = this.drainFailureMarkers(session);
162
169
  return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools, abort: r.abort };
@@ -186,6 +193,24 @@ export class HookRegistry implements HookRegistrar {
186
193
  return {};
187
194
  }
188
195
 
196
+ // validate asks EVERY hook — no short-circuit — so neither the verdict nor the message depends on which
197
+ // validator happened to load first: refused if any refuses, with every refusal's errors (a field two
198
+ // validators both reject is said once). `[]` from any is "valid"; `undefined` from all is no opinion.
199
+ async runValidate(ctx: Omit<ValidateContext, 'removeHook'>): Promise<ToolInputError[] | undefined> {
200
+ let opinion: ToolInputError[] | undefined;
201
+ const seen = new Set<string>();
202
+ for (const hook of this.on('validate')) {
203
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('validate', hook) }));
204
+ if (!r) continue;
205
+ opinion ??= [];
206
+ for (const e of r) {
207
+ const key = `${e.path}\0${e.message}`;
208
+ if (!seen.has(key)) { seen.add(key); opinion.push(e); }
209
+ }
210
+ }
211
+ return opinion;
212
+ }
213
+
189
214
  // toolresult folds the tool result through each hook — a transform pipeline (redaction, truncation);
190
215
  // a hook that returns nothing just observes (auditing). Returns the final result.
191
216
  async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export type * from './types.js';
12
- export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT } from './types.js';
12
+ export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT, importPermitted, importableByName, permitted, refused, refusalMessage } from './types.js';
13
13
 
14
14
  export * from './base64.js';
15
15
  export * from './bytes.js';
@@ -17,8 +17,11 @@ export * from './store-query.js';
17
17
  export * from './errors.js';
18
18
  export * from './vault.js';
19
19
  export * from './provider-patch.js';
20
+ export * from './provider-models.js';
20
21
  export * from './session.js';
21
22
  export * from './invoke-tool.js';
23
+ export * from './fetch-origin.js';
24
+ export * from './contract-arms.js';
22
25
 
23
26
  // The plugin half of the machine: MatbotPlugin/MatbotServices/MatbotRuntime, the mount-table *consumer*
24
27
  // contract, and the API version a plugin declares. Host assembly is in ./host-machine.ts.
@@ -1,5 +1,5 @@
1
1
  import type { MatbotMachine, MatbotPlugin } from './plugin.js';
2
- import type { ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField, PermissionGate } from './types.js';
2
+ import type { Tool, ToolContext, ToolEvent, ToolResultFor, ToolProxy, PromptFn, FormField, PermissionGate } from './types.js';
3
3
  import { askPermissionGate } from './permission-gate.js';
4
4
 
5
5
  /** The host's plugin hot-load ops, as both the runner and {@link invokeTool} hold them. */
@@ -33,21 +33,56 @@ export function bindPluginOps(host: PluginOps, prompt: PromptFn): Pick<ToolConte
33
33
  * stops a plugin addressing a gate it does not own — tool-name collision on `register` is already
34
34
  * resolved, so the name is not something a second plugin can quietly claim.
35
35
  *
36
+ * A request may name a `namespace` in place of the tool's name — for a tool whose name is chosen at RUN
37
+ * TIME, which is a function-tools function — and it is honoured only if `tools` resolves it to a tool
38
+ * stamped with the same plugin as this one. Both stamps are the host's (`register`, and the `screen` hook
39
+ * that offered a turn tool), so a plugin can name only a gate it already owns. Without `tools`, or for a
40
+ * tool known only by a bare name, there is nothing to check against, so a namespace is refused.
41
+ *
36
42
  * `ask` is the RAW per-turn prompt, never a stand-in that answers with a field's default: `undefined`
37
43
  * is how "no human is reachable" reaches the policy, and a substitute would make that undecidable.
38
44
  * `gate` is the registered `PermissionGate`; it is a non-optional service, but a hand-assembled
39
45
  * machine (a test, a minimal embedder) may still have none, so the asking default stands in.
40
46
  */
41
47
  export function bindGate(
42
- gate: PermissionGate | undefined,
43
- toolName: string,
44
- ask: PromptFn | undefined,
48
+ gate: PermissionGate | undefined,
49
+ tool: string | Pick<Tool, 'name' | 'pluginName'>,
50
+ ask: PromptFn | undefined,
51
+ tools?: { resolve(name: string): Pick<Tool, 'pluginName'> | null },
45
52
  ): Pick<ToolContext, 'gate'> {
53
+ const name = typeof tool === 'string' ? tool : tool.name;
54
+ const owner = typeof tool === 'string' ? undefined : tool.pluginName;
46
55
  return {
47
- gate: req => (gate ?? askPermissionGate).decide({ ...req, gate: `${toolName}.${req.gate}` }, ask),
56
+ gate: async ({ namespace, ...req }) => {
57
+ if (namespace !== undefined && namespace !== name
58
+ && (owner === undefined || tools?.resolve(namespace)?.pluginName !== owner)) {
59
+ throw new Error(`Tool "${name}" cannot ask permission under "${namespace}": a gate namespace must be a tool registered by the same plugin.`);
60
+ }
61
+ const qualified = { ...req, gate: `${namespace ?? name}.${req.gate}` };
62
+ const decision = await (gate ?? askPermissionGate).decide(qualified, ask);
63
+ if (!decision.allowed && decision.source === 'fallback') reportUnattended(name, qualified.gate, qualified.subject);
64
+ return decision;
65
+ },
48
66
  };
49
67
  }
50
68
 
69
+ // When nobody can be asked (a background job, a trigger, `POST /tools/:name`), a gate refuses, and the model
70
+ // is told so. Nobody else would be: the operation simply did not happen, in a process nobody is watching. So
71
+ // it is logged, naming what a standing answer would have to say to allow it. At error level, because a
72
+ // background job's process keeps only errors (its output is its result); and at most once an hour per gate
73
+ // and subject, because a dashboard polling a gated tool would otherwise log every poll.
74
+ const UNATTENDED_REPORT_MS = 60 * 60 * 1000;
75
+ const unattendedReported = new Map<string, number>();
76
+ function reportUnattended(tool: string, gate: string, subject: string): void {
77
+ const key = `${gate}\0${subject}`;
78
+ const now = Date.now();
79
+ if (now - (unattendedReported.get(key) ?? -Infinity) < UNATTENDED_REPORT_MS) return;
80
+ unattendedReported.set(key, now);
81
+ console.error(`[gate] Refused ${gate} for "${subject}" (tool ${tool}): nobody was there to ask. ` +
82
+ `To allow it unattended, give a standing answer for that gate and subject (e.g. answer "Always" ` +
83
+ `when it is next asked in the UI, or configure the permission policy).`);
84
+ }
85
+
51
86
  /**
52
87
  * Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
53
88
  * forwarding a call to another tool should pass its own {@link ToolContext} straight through as
@@ -97,7 +132,7 @@ export function invokeTool<K extends string, const P>(
97
132
  // The raw `opts.prompt`, not the rejecting stand-in above: a gate must be able to tell "nobody is
98
133
  // here" from "a human answered", and `POST /tools/:name` reaching a privileged tool is exactly the
99
134
  // non-interactive case the request's `fallback` exists to answer.
100
- ...bindGate(machine.PermissionGate, name, opts.prompt),
135
+ ...bindGate(machine.PermissionGate, tool, opts.prompt, machine.tools),
101
136
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
102
137
  ...(opts.turnTools !== undefined ? { turnTools: opts.turnTools } : {}),
103
138
  ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
@@ -1,6 +1,6 @@
1
1
  import { CONFIRM_NO, CONFIRM_YES } from './types/messages.js';
2
2
  import type { FormField } from './types/messages.js';
3
- import type { PermissionGate } from './types/permission.js';
3
+ import { permitted, refused, type PermissionGate } from './types/permission.js';
4
4
 
5
5
  /**
6
6
  * The bare asking `PermissionGate`: ask through the channel in scope, and answer `req.fallback` when
@@ -17,8 +17,11 @@ import type { PermissionGate } from './types/permission.js';
17
17
  */
18
18
  export const askPermissionGate: PermissionGate = {
19
19
  async decide(req, ask) {
20
- if (ask === undefined) return req.fallback;
20
+ // Two layers, so two sources: nobody to ask is `fallback`, and anything a human answered — a no
21
+ // included — is `user`.
22
+ if (ask === undefined) return req.fallback ? permitted('fallback') : refused('fallback');
21
23
  const field: FormField = { name: 'confirm', label: req.label, type: 'confirm', default: CONFIRM_NO };
22
- return (await ask(field)).trim().toLowerCase() === CONFIRM_YES;
24
+ const yes = (await ask(field)).trim().toLowerCase() === CONFIRM_YES;
25
+ return yes ? permitted('user') : refused('user');
23
26
  },
24
27
  };
@@ -0,0 +1,106 @@
1
+ import type { ProviderAdapter, ProviderConfig } from './types.js';
2
+ import type { Vault } from './types/vault.js';
3
+
4
+ export const MODEL_LIST_TIMEOUT_MS = 8_000;
5
+
6
+ export type ModelCheck =
7
+ | { kind: 'listed' }
8
+ | { kind: 'unlisted'; count: number; closest: string[] }
9
+ /** Nothing could be compared: no `models`, a missing key, a refusal, a timeout, an empty list. */
10
+ | { kind: 'unchecked'; reason: string };
11
+
12
+ /**
13
+ * Whether `model` is one the endpoint lists. Not exact equality, because an endpoint accepts more names
14
+ * than it lists: Anthropic an undated alias of a dated id (`claude-sonnet-4-5` for
15
+ * `claude-sonnet-4-5-20250929`) and a `-latest` one, Ollama a bare name for its `:latest` tag. Gemini's
16
+ * lists say `models/<id>`, which either spelling of the model matches.
17
+ */
18
+ export function modelListed(model: string, ids: readonly string[]): boolean {
19
+ const m = model.replace(/^models\//, '');
20
+ return ids.some(raw => {
21
+ const id = raw.replace(/^models\//, '');
22
+ return id === m || id === `${m}:latest`
23
+ || (id.startsWith(`${m}-`) && /^\d{8}$/.test(id.slice(m.length + 1)))
24
+ || (m.endsWith('-latest') && id.startsWith(m.slice(0, -'latest'.length)));
25
+ });
26
+ }
27
+
28
+ function editDistance(a: string, b: string): number {
29
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
30
+ for (let i = 1; i <= a.length; i++) {
31
+ const row = [i];
32
+ for (let j = 1; j <= b.length; j++) {
33
+ row[j] = Math.min(prev[j]! + 1, row[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
34
+ }
35
+ prev = row;
36
+ }
37
+ return prev[b.length]!;
38
+ }
39
+
40
+ /**
41
+ * The ids nearest `id`, best first. Shared name segments (family, size, quantisation) find a model's
42
+ * siblings from a partial name (`qwen3-4b`, `sonnet-4-6`); a spelling within two edits of an id comes
43
+ * ahead of them all, being what a typo looks like.
44
+ */
45
+ export function closestIds(id: string, ids: readonly string[], n = 5): string[] {
46
+ const parts = (s: string): string[] => s.toLowerCase().split(/[-_.:/\s]+/).filter(Boolean);
47
+ const want = parts(id);
48
+ return ids
49
+ .map(candidate => {
50
+ const have = parts(candidate);
51
+ const score = want.filter(w => have.some(h => h === w)).length * 2
52
+ + want.filter(w => have.some(h => h !== w && (h.includes(w) || w.includes(h)))).length;
53
+ return { id: candidate, score, dist: editDistance(id.toLowerCase(), candidate.toLowerCase()) };
54
+ })
55
+ .filter(x => x.score > 0 || x.dist <= 2)
56
+ .sort((a, b) => Number(a.dist > 2) - Number(b.dist > 2) || b.score - a.score || a.dist - b.dist || a.id.localeCompare(b.id))
57
+ .slice(0, n)
58
+ .map(x => x.id);
59
+ }
60
+
61
+ /**
62
+ * Asks the endpoint which models it serves and compares `config.model` against them, so a typo is caught
63
+ * where it is made rather than as a 404 on the next turn. Listing SENDS the profile's credential, so a
64
+ * caller asks only once that credential may go to `config.endpoint` — the same exposure the profile's
65
+ * own first turn has.
66
+ *
67
+ * Never throws, and `unchecked` is not a failure: most endpoints that cannot list (a gateway, a key not
68
+ * yet stored, a server with no `/models`) still serve the model fine.
69
+ */
70
+ export async function checkProviderModel(
71
+ adapter: Pick<ProviderAdapter, 'models'>,
72
+ config: ProviderConfig,
73
+ vault: Pick<Vault, 'resolve'>,
74
+ timeoutMs: number = MODEL_LIST_TIMEOUT_MS,
75
+ ): Promise<ModelCheck> {
76
+ if (adapter.models === undefined) return { kind: 'unchecked', reason: 'the adapter cannot list its models' };
77
+ let resolved: ProviderConfig;
78
+ try {
79
+ const credentials: Record<string, string> = {};
80
+ for (const [k, v] of Object.entries(config.credentials ?? {})) credentials[k] = await vault.resolve(v);
81
+ resolved = {
82
+ ...config,
83
+ ...(config.credentials !== undefined ? { credentials } : {}),
84
+ ...(config.endpoint !== undefined ? { endpoint: await vault.resolve(config.endpoint) } : {}),
85
+ };
86
+ } catch (e) {
87
+ return { kind: 'unchecked', reason: e instanceof Error ? e.message : String(e) };
88
+ }
89
+
90
+ const signal = AbortSignal.timeout(timeoutMs);
91
+ let ids: readonly string[];
92
+ try {
93
+ // Raced as well as signalled: an adapter may ignore the signal, and a hung list must not hold up the tool.
94
+ ids = await Promise.race([
95
+ adapter.models(resolved, signal),
96
+ new Promise<never>((_, reject) => signal.addEventListener('abort', () => reject(new Error(`no answer within ${timeoutMs / 1000}s`)), { once: true })),
97
+ ]);
98
+ } catch (e) {
99
+ // An endpoint's error body is often pretty-printed JSON; the reason is shown on one line.
100
+ const why = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, ' ');
101
+ return { kind: 'unchecked', reason: why.length > 200 ? `${why.slice(0, 200)}…` : why };
102
+ }
103
+ if (ids.length === 0) return { kind: 'unchecked', reason: 'the endpoint listed no models' };
104
+ if (modelListed(config.model, ids)) return { kind: 'listed' };
105
+ return { kind: 'unlisted', count: ids.length, closest: closestIds(config.model, ids) };
106
+ }
@@ -1,5 +1,11 @@
1
1
  import type { ProviderConfig } from './types.js';
2
2
  import type { ProviderPatch } from './types/builtin-tools.js';
3
+ import type { VaultSpec } from './types/vault.js';
4
+
5
+ /** The vault key a profile's own credential is stored under by default: `MATBOT_API_KEY_<PROFILE>`. */
6
+ export function credentialKeyName(profile: string): string {
7
+ return `MATBOT_API_KEY_${profile.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
8
+ }
3
9
 
4
10
  /**
5
11
  * The `provider update` policy, written once so every implementer of it behaves identically — the same
@@ -12,10 +18,16 @@ import type { ProviderPatch } from './types/builtin-tools.js';
12
18
  * which is what makes a read-modify-write something a caller can actually carry out. (Contrast
13
19
  * `default_settings`, which merges per key: there the keys are matbot's own.)
14
20
  *
15
- * `credentials` and `module` are absent from `ProviderPatch` and therefore ride through untouched; see
16
- * that type for why neither belongs in an update.
21
+ * `credentials` and `module` are absent from `ProviderPatch`; see that type for why neither belongs in an
22
+ * update. `module` rides through untouched. `credentials` does too — unless the endpoint now names another
23
+ * ORIGIN (scheme, host and port; clearing it back to the adapter's default counts), because the credential
24
+ * is sent wherever the endpoint points: an endpoint change was a way to post the key to any host at all,
25
+ * with the update's own prompt never mentioning it. So the profile is pointed at fresh vault keys instead,
26
+ * none of which exists yet, and whoever means it to have a credential there supplies one out of band
27
+ * (`plugin store-key`). The old keys stay in the vault, since another profile may share them.
28
+ * {@link movedCredentialKeys} names the new ones.
17
29
  */
18
- export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch): ProviderConfig {
30
+ export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch, vault: Pick<VaultSpec, 'hasKey'>): ProviderConfig {
19
31
  const next: ProviderConfig = { ...cur };
20
32
  // Spelled out per field rather than looped: `exactOptionalPropertyTypes` makes "assign, or delete if
21
33
  // null" a different statement for each type, and a loop only reaches them through a cast that would
@@ -24,9 +36,34 @@ export function applyProviderPatch(cur: ProviderConfig, patch: ProviderPatch): P
24
36
  if (patch.endpoint !== undefined) { if (patch.endpoint === null) delete next.endpoint; else next.endpoint = patch.endpoint; }
25
37
  if (patch.parameters !== undefined) { if (patch.parameters === null) delete next.parameters; else next.parameters = patch.parameters; }
26
38
  if (patch.maxRounds !== undefined) { if (patch.maxRounds === null) delete next.maxRounds; else next.maxRounds = patch.maxRounds; }
39
+ if (cur.credentials !== undefined && originOf(cur.endpoint) !== originOf(next.endpoint)) {
40
+ const base = credentialKeyName(cur.name);
41
+ const taken = new Set<string>();
42
+ const fresh: Record<string, string> = {};
43
+ for (const field of Object.keys(cur.credentials)) {
44
+ const stem = field === 'apiKey' ? base : `${base}_${field.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
45
+ let key = stem;
46
+ for (let n = 2; vault.hasKey(key) || taken.has(key); n++) key = `${stem}_${n}`;
47
+ taken.add(key);
48
+ fresh[field] = `\${${key}}`;
49
+ }
50
+ next.credentials = fresh;
51
+ }
27
52
  return next;
28
53
  }
29
54
 
55
+ /** The vault keys a patched profile now reads its credential from, where {@link applyProviderPatch} took
56
+ * it off because the endpoint moved origin; empty when it did not. */
57
+ export function movedCredentialKeys(cur: ProviderConfig, next: ProviderConfig): readonly string[] {
58
+ if (cur.credentials === next.credentials || next.credentials === undefined) return [];
59
+ return Object.values(next.credentials).map(v => v.slice(2, -1));
60
+ }
61
+
62
+ // An endpoint that is not a URL is compared as written: nothing can say it is the same place.
63
+ function originOf(endpoint: string | undefined): string | undefined {
64
+ return endpoint !== undefined && URL.canParse(endpoint) ? new URL(endpoint).origin : endpoint;
65
+ }
66
+
30
67
  /** The fields a patch actually changes, in a stable order — for a confirmation prompt or a result message. */
31
68
  export function patchedFields(patch: ProviderPatch): readonly ('model' | 'endpoint' | 'parameters' | 'maxRounds')[] {
32
69
  return (['model', 'endpoint', 'parameters', 'maxRounds'] as const).filter(f => patch[f] !== undefined);
@@ -184,7 +184,8 @@ export interface ProviderActionResult {
184
184
  * `provider list` does not report back (it projects to `hasCredentials`, and a secret must not ride the
185
185
  * transcript) — so a caller cannot read-modify-write it, and an `update` that accepted it would have to
186
186
  * guess between rewriting a `${NAME}` reference and replacing a literal. Credentials are
187
- * `plugin store-key`'s, which addresses the vault name directly. And **`module`**, because changing the
187
+ * `plugin store-key`'s, which addresses the vault name directly. (An `endpoint` moved to another origin does
188
+ * change them, by policy — see `applyProviderPatch`.) And **`module`**, because changing the
188
189
  * adapter re-opens every resolution question `add` answers (is it loaded, does it resolve by package
189
190
  * name, is it even a provider adapter); `remove` + `add` is the honest route for that.
190
191
  *
@@ -17,6 +17,9 @@ export type TurnEvent =
17
17
  | { type: 'thinking'; delta: string; traceId: string }
18
18
  | { type: 'system-context'; text: string; traceId: string }
19
19
  | ({ type: 'usage'; traceId: string } & Usage)
20
+ // The provider's own pre-answer work (a local model downloading or loading) — its `progress`
21
+ // CompletionEvent, forwarded. Live-only; superseded by whatever the turn does next.
22
+ | { type: 'progress'; pct: number; message?: string; traceId: string }
20
23
 
21
24
  // ── tool execution ──
22
25
  | { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
@@ -48,8 +48,12 @@ export interface RunConfig {
48
48
  * ephemeral context — supersede rather than follow), and/or append durable `markers`
49
49
  * to the committed session (LLM-invisible annotations — the second durable-write point
50
50
  * after `screen`, safe because it too fires once per turn).
51
+ * validate the EXECUTOR, before every call through every door — the runner, `POST /tools/:name`,
52
+ * `invokeTool` — and `tools.validate`. Not a runner channel, so it carries no session.
53
+ * Every hook runs; the call is refused if ANY returns errors. `undefined` is no opinion,
54
+ * never "valid". See {@link ValidateContext}.
51
55
  */
52
- export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup';
56
+ export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup' | 'validate';
53
57
 
54
58
  export interface ScreenContext {
55
59
  session: Session;
@@ -181,6 +185,41 @@ export interface ToolResultContext {
181
185
  /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
182
186
  removeHook(): void;
183
187
  }
188
+ /**
189
+ * One rejected field. `path` is dotted, as a caller would WRITE the access (`.items[0].name`) rather
190
+ * than as JSON Pointer — the reader is either a model repairing its own call or a developer looking at
191
+ * a 422, and both write dots. `.` alone is the whole value.
192
+ *
193
+ * `value` is what was actually supplied there, and is what makes the difference between a message a
194
+ * model can act on and one it has to guess at. Optional because a validator may not have it, and
195
+ * `undefined` specifically means ABSENT: JSON carries no undefined, so there is nothing to show for a
196
+ * missing property beyond saying it is missing.
197
+ */
198
+ export interface ToolInputError {
199
+ path: string;
200
+ message: string;
201
+ value?: unknown;
202
+ }
203
+
204
+ /**
205
+ * A tool call about to execute, for a validator to judge — `json-validation` against the tool's
206
+ * `inputSchema`, `ts-validation` against its `ToolContract` params type. A hook rather than a service
207
+ * so several co-exist with no reference to each other: each is consulted, a call is refused if any
208
+ * refuses it, and unloading one removes only its own opinion. It was a single-valued service key, which
209
+ * forced the second validator to capture the first and delegate to it — so the pair depended on load
210
+ * order, and on each other.
211
+ *
212
+ * Return errors to refuse, `[]` to say the input is valid, or nothing for NO OPINION (no contract for
213
+ * this tool, or one that cannot be honestly validated). A throw is no opinion too, logged.
214
+ */
215
+ export interface ValidateContext {
216
+ readonly tool: string;
217
+ readonly parameters: unknown;
218
+ signal: AbortSignal;
219
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
220
+ removeHook(): void;
221
+ }
222
+
184
223
  // The toolresult hook returns `{ result }` to replace the tool's result, or nothing to leave it
185
224
  // (and just observe) — a trivial single-field return, inlined in the Hook union like `contribute`'s.
186
225
 
@@ -235,4 +274,5 @@ export type Hook =
235
274
  | { on: 'contribute'; priority?: number; pluginName?: string; handler(ctx: ContributeContext): Message[] | void | Promise<Message[] | void> }
236
275
  | { on: 'toolcall'; priority?: number; pluginName?: string; handler(ctx: ToolCallContext): ToolCallResult | void | Promise<ToolCallResult | void> }
237
276
  | { on: 'toolresult'; priority?: number; pluginName?: string; handler(ctx: ToolResultContext): { result: unknown } | void | Promise<{ result: unknown } | void> }
238
- | { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> };
277
+ | { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> }
278
+ | { on: 'validate'; priority?: number; pluginName?: string; handler(ctx: ValidateContext): ToolInputError[] | undefined | void | Promise<ToolInputError[] | undefined | void> };
@@ -26,6 +26,87 @@ export interface PermissionRequest {
26
26
  /** What this site does when nothing can be asked and nothing decides — today's non-interactive
27
27
  * behaviour, stated once per site instead of implied at each. */
28
28
  fallback: boolean;
29
+ /**
30
+ * The standing answers this act may be remembered as, NARROWEST FIRST — what a policy offers beside
31
+ * "just this once", each as the subject it would store and the prose a human reads. A policy consults
32
+ * {@link subject} and then each of these in order, and may store whichever was chosen; absent, the
33
+ * only standing answer is the exact subject, labelled by the policy.
34
+ *
35
+ * The call site supplies the SUBJECTS because only it knows how its own generalise: a module specifier
36
+ * widens by protocol or by directory, a plugin specifier by registry scope, and neither rule is one a
37
+ * policy could derive without knowing the gate. Which is the point — a policy does **set membership**
38
+ * over strings it never parses, so no gate id is hardcoded in it and a gate this build never compiled
39
+ * against generalises the same way.
40
+ *
41
+ * It supplies the LABELS for the same reason it supplies {@link label}: a subject is a key, chosen to
42
+ * be stable and unambiguous, and keys make terrible prose. `#execute node:fs/` is a correct key and an
43
+ * unusable question. A policy rendering `Always allow "<subject>"` can only ever show the key, because
44
+ * saying it in English needs exactly the knowledge the subject was built to avoid requiring.
45
+ *
46
+ * A policy must never store a subject that was not offered here: that is what keeps this from widening
47
+ * the memory semantics of every other gate.
48
+ */
49
+ standing?: readonly { readonly subject: string; readonly label: string }[];
50
+ }
51
+
52
+ /**
53
+ * Which test in the cascade produced an answer — not *who* answered, but which of the layers a policy
54
+ * consults got there first: a human at the prompt, a standing answer out of configuration, or the call
55
+ * site's own `fallback` because nothing else could decide. matbot's own three are named here; the type
56
+ * stays **open**, because a replacement policy has layers of its own (`'user-stored-preference'`, an
57
+ * org directory, a rule table) and a closed union would mean a policy could not report truthfully
58
+ * without a plugin-api change. Treat an unrecognised source exactly as the prose helper does: report it.
59
+ */
60
+ export type PermissionSource =
61
+ /** A human was asked and answered. */
62
+ | 'user'
63
+ /** A standing answer in force — an installation's configured floor or a remembered one; the settings
64
+ * layering that holds them makes the two indistinguishable by construction, so they share a source. */
65
+ | 'configuration'
66
+ /** Nothing decided and nobody was reachable: {@link PermissionRequest.fallback} stands. */
67
+ | 'fallback'
68
+ | (string & {});
69
+
70
+ /**
71
+ * The answer: whether the act may proceed, and which layer said so.
72
+ *
73
+ * `source` exists for the REPORT rather than the control flow — a refused tool call that says only
74
+ * "Cancelled." reads to a model as a malfunction, so it hunts for a workaround or a bug to fix. A
75
+ * denial that names its origin is a decision it can relay instead (see {@link refusalMessage}).
76
+ *
77
+ * Nothing branches on `source`, and a gate must not be given one to branch on: the outcome is
78
+ * `allowed`, and a call site that treated `'fallback'` differently from a human's "no" would be
79
+ * reintroducing the policy the gate exists to hold.
80
+ */
81
+ export interface PermissionDecision {
82
+ allowed: boolean;
83
+ source: PermissionSource;
84
+ }
85
+
86
+ /** The allow/deny pair, spelled once. */
87
+ export const permitted = (source: PermissionSource): PermissionDecision => ({ allowed: true, source });
88
+ export const refused = (source: PermissionSource): PermissionDecision => ({ allowed: false, source });
89
+
90
+ /**
91
+ * What a refused call site tells the caller — including the model, which is the reader this exists for.
92
+ * A bare "Cancelled." is indistinguishable from a fault, and a model that reads a decision as a fault
93
+ * goes looking for the fault: it retries under another spelling, probes for a bug, or reaches for a
94
+ * route around the gate. So the prose names the layer that refused and says, for the two sources where
95
+ * it is true, that there is nothing here to work around.
96
+ *
97
+ * `act` names the operation in the caller's own words when it has them ("Install plugin \"@x/foo\"");
98
+ * the sentence stands without it. An unrecognised source is reported as itself rather than guessed at —
99
+ * a policy's own layer name is prose a human chose, and saying it is more use than "refused".
100
+ */
101
+ export function refusalMessage(decision: PermissionDecision, act?: string): string {
102
+ const what = act !== undefined ? `${act}: cancelled` : 'Cancelled';
103
+ const stop = 'This is a decision, not a fault — do not retry it or look for a way around it.';
104
+ switch (decision.source) {
105
+ case 'user': return `${what} — the user declined. ${stop}`;
106
+ case 'configuration': return `${what} — the installation's permission policy does not allow it. ${stop}`;
107
+ case 'fallback': return `${what} — permission was required and nobody could be asked, so the non-interactive default applied. Nothing is broken; it needs a user present, or a standing answer in the installation's policy.`;
108
+ default: return `${what} — refused by the permission policy (${decision.source}). ${stop}`;
109
+ }
29
110
  }
30
111
 
31
112
  /**
@@ -39,8 +120,8 @@ export interface PermissionRequest {
39
120
  * means **no human is reachable**; a policy that wants to behave differently then simply checks it.
40
121
  *
41
122
  * A gate that wants "my rules, else the previous behaviour" captures the gate it displaces and
42
- * delegates to it (the `ToolCallValidator` idiom), so a pair composes in either load order.
123
+ * delegates to it, so a pair composes in either load order.
43
124
  */
44
125
  export interface PermissionGate {
45
- decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<boolean>;
126
+ decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<PermissionDecision>;
46
127
  }
@@ -217,6 +217,14 @@ export type CompletionEvent =
217
217
  * a larger budget) is a `followup` hook's business, not the harness's.
218
218
  */
219
219
  | { type: 'truncated'; reason: 'max-tokens' | 'stream-end'; raw?: string }
220
+ /**
221
+ * Progress on work the provider must finish before it can answer at all — an on-device model
222
+ * downloading or loading. `pct` is 0–100, as a tool's `progress` event. Live-only: forwarded as the
223
+ * turn's `progress` event and never persisted, being a fact about the moment rather than the reply.
224
+ * Not a substitute for `thinking`, which is the model's own output; putting a status line there made
225
+ * it read as something the model thought.
226
+ */
227
+ | { type: 'progress'; pct: number; message?: string }
220
228
  | ({ type: 'usage' } & Usage)
221
229
  | { type: 'done' };
222
230
 
@@ -256,4 +264,11 @@ export interface ProviderAdapter {
256
264
  signal: AbortSignal
257
265
  ): AsyncIterable<CompletionEvent>;
258
266
  health(): Promise<HealthStatus>;
267
+ /**
268
+ * The model ids this adapter can run under `config`, for a person choosing one (the setup wizards) and
269
+ * for `checkProviderModel`, which catches a mistyped one. Absent where the adapter cannot enumerate
270
+ * them, and a rejection means the same: the caller asks for a model name instead, or accepts it
271
+ * unchecked. `config` carries resolved credentials, which a remote list is sent with.
272
+ */
273
+ models?(config: ProviderConfig, signal?: AbortSignal): Promise<string[]>;
259
274
  }
@@ -1,4 +1,4 @@
1
- import type { Hook } from './hooks.js';
1
+ import type { Hook, ToolInputError } from './hooks.js';
2
2
  import type { Session } from './messages.js';
3
3
  import type { ProviderConfig } from './provider.js';
4
4
  import type { Tool } from './tools.js';
@@ -34,6 +34,11 @@ export interface ToolRegistry {
34
34
  resolve(name: string): Tool | null;
35
35
  list(): readonly Tool[];
36
36
  removeByPlugin(pluginName: string): void;
37
+ /** Judge `parameters` for the tool `name` exactly as its executor will before running it: every
38
+ * `validate` hook. Errors refuse, `[]` is valid, `undefined` means nothing had an opinion. For a
39
+ * caller that stores a call to make later (a trigger's `invoke`), so it can be refused while its
40
+ * author is present. Optional: a registry stood up by hand need not validate. */
41
+ validate?(name: string, parameters: unknown): Promise<ToolInputError[] | undefined>;
37
42
  }
38
43
 
39
44
  /** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
@@ -2,7 +2,7 @@ import type { FileHandle, FileStore } from './files.js';
2
2
  import type { FormField, MessageContent, Session } from './messages.js';
3
3
  import type { MatbotPlugin } from '../plugin.js';
4
4
  import type { JSONSchema } from './primitives.js';
5
- import type { PermissionRequest } from './permission.js';
5
+ import type { PermissionDecision, PermissionRequest } from './permission.js';
6
6
  import type { Vault } from './vault.js';
7
7
  import type { SessionAppender } from './session-append.js';
8
8
 
@@ -230,15 +230,25 @@ export interface ToolContext {
230
230
  prompt: PromptFn;
231
231
  /**
232
232
  * Ask permission for a privileged operation — install a plugin, remove a provider profile, add an
233
- * MCP server. Resolves `true` to proceed. The call site declares *that* acceptance is needed; the
234
- * registered {@link PermissionGate} decides *how* it is obtained (the default asks the user).
233
+ * MCP server. The call site declares *that* acceptance is needed; the registered
234
+ * {@link PermissionGate} decides *how* it is obtained (the default asks the user).
235
235
  *
236
236
  * `gate` is the SUFFIX (`'add'`): the host qualifies it with this tool's registered name, so one
237
237
  * policy covers both runtimes' implementations of a tool (node's `plugin` and the browser's) and a
238
238
  * plugin cannot address a gate it does not own. `fallback` is what happens when no human is
239
239
  * reachable and no policy decides.
240
+ *
241
+ * `namespace` replaces the tool's name as the qualifier, for a tool whose NAME is chosen at run time
242
+ * (a `function-tools` function asks under `tool_function`): qualified by a model-chosen name, every
243
+ * definition would mint its own gate id, so a standing answer filed under one could never cover
244
+ * another. It must name a tool registered by the SAME plugin as this one — the host checks, against
245
+ * the plugin it stamped on both — so it widens nothing: a plugin still cannot address another's gate.
246
+ *
247
+ * The outcome is `allowed`; `source` names the layer that answered, for the refusal to REPORT (see
248
+ * {@link refusalMessage}) — never to branch on. NOTE the shape: this resolves to an object, so
249
+ * `if (!await ctx.gate(…))` is always false. Test `.allowed`.
240
250
  */
241
- gate(req: PermissionRequest): Promise<boolean>;
251
+ gate(req: PermissionRequest & { namespace?: string }): Promise<PermissionDecision>;
242
252
  /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
243
253
  * `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
244
254
  * re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
@@ -304,6 +314,12 @@ export interface Tool<R = unknown> {
304
314
  * its contract as a `ToolContracts` augmentation instead and omits this. Foreign tools (e.g. MCP proxies)
305
315
  * that have neither fall back to the loose `inputSchema`. */
306
316
  toolContract?: string;
317
+ /** Declarations (`interface`, `type`) that {@link toolContract} names and that exist nowhere else — a
318
+ * function package's own types, which its exports' contracts refer to by name. In scope for this tool's
319
+ * contract only: a consumer declares them in a namespace of their own beside the contract
320
+ * (`contractArms`), so two tools' types of one name never meet. Absent ⇒ the contract names only
321
+ * plugin-api types and TypeScript's own. */
322
+ toolContractTypes?: string;
307
323
  executor: ToolExecutor<R>;
308
324
  pluginName?: string;
309
325
  }
@@ -18,25 +18,194 @@ export interface TypeScriptStripper {
18
18
  }
19
19
 
20
20
  /**
21
- * Optional, host-supplied: compiles already-stripped JavaScript into an async function whose SYNCHRONOUS
22
- * execution is bounded. Model-authored code (a `tool_function` body) runs on the one event loop every
23
- * session, frontend and schedule shares, so a loop that never awaits freezes all of them — and nothing
24
- * in-process can stop it, an abort signal included, since stopping needs the loop to yield. Only the
25
- * stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
21
+ * Optional, host-supplied: turns model-authored TypeScript into something runnable, and is the
22
+ * environment it runs in. How it gets there — strip and compile, transform, interpret — is its own
23
+ * business; the rest of this interface is one value it is GIVEN per call, and two facts about its
24
+ * environment that nothing else can know.
26
25
  *
27
- * Absent ⇒ a consumer compiles with `new AsyncFunction` and runs unbounded, which is all a platform with
28
- * no way to interrupt synchronous code (the browser) can do. An embedder may register its own.
26
+ * Its first job is bounding a body's SYNCHRONOUS execution. Model-authored code runs on the one event
27
+ * loop every session, frontend and schedule shares, so a loop that never awaits freezes all of them —
28
+ * and nothing in-process can stop it, an abort signal included, since stopping needs the loop to yield.
29
+ * Only the stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
30
+ *
31
+ * Absent ⇒ a consumer compiles the code itself however it can, which drops every part of this at once
32
+ * and not just the bound: nothing is withheld from the body, `import()` reaches whatever the platform
33
+ * natively resolves rather than the loader it was passed, and {@link FunctionRunSettings.permittedImports} has no carrier and
34
+ * so restricts nothing. That is all a platform with no way to interrupt synchronous code
35
+ * and no module hook (the browser) can do, and it is the browser's permanent state rather than a
36
+ * misconfiguration. It is node's DEFAULT too: no host seeds a runner, so until an installation loads one
37
+ * (`@matatbread/matbot-function-runner-node`, or its own) a body runs in the host's process with full
38
+ * access — unbounded, with every global, and with `import()` reaching node's own loader unasked. Nothing
39
+ * warns about that at boot, because it is the configured behaviour rather than a fault.
40
+ */
41
+ /**
42
+ * One invocation's reading of a {@link FunctionRunner}'s configuration ({@link FunctionRunner.settings}).
43
+ * A runner may carry its own fields beside these — the node runner's time bound — and reads them back
44
+ * off the value handed to {@link FunctionRunner.compile}'s callable; a consumer reads only what is
45
+ * declared here and otherwise passes the value through untouched.
29
46
  */
47
+ export interface FunctionRunSettings {
48
+ /**
49
+ * An optional RESTRICTION on what a body may import, as exact specifiers or prefixes (`['node:']`).
50
+ *
51
+ * **Absent ⇒ no restriction**, which is the default: every specifier may be asked about, and the
52
+ * per-import {@link PermissionGate} decision is the control. That way round because a gate that is
53
+ * never reached is not a gate — an allow-list defaulting to empty refuses before anyone can be asked,
54
+ * which is precisely the behaviour a permission prompt exists to replace. An empty array means the
55
+ * opposite of absent: nothing may be imported, and nobody is asked.
56
+ *
57
+ * What it is FOR is the case a prompt cannot express: a deployment that wants a module forbidden
58
+ * outright, with no human able to allow it. A policy has no stored "always deny" — a refusal is a
59
+ * decision about one act — so this is the only way to say "never", and it is installation authoring.
60
+ *
61
+ * It comes from the runner because the runner IS the execution environment, and because two consumers
62
+ * need one answer: the gate in front of the import, and the type index that declares the reachable set
63
+ * to the author. Each reads it once per invocation, through {@link FunctionRunner.settings}.
64
+ *
65
+ * An entry matches a specifier exactly, or as a prefix ending at a BOUNDARY — its own trailing `/` or
66
+ * `:`, or the next `/` in the specifier. So `node:` admits every builtin and `node:fs` admits
67
+ * `node:fs/promises`, while `lodash` does NOT admit `lodashhack`: a bare string prefix would let an
68
+ * entry admit a sibling whose name merely begins the same way, which is the whole value of naming one.
69
+ * It bounds what may be ASKED — it grants nothing by itself — and only ever among what
70
+ * {@link importableByName} admits, so an entry naming a path or a URL admits nothing.
71
+ */
72
+ readonly permittedImports?: readonly string[];
73
+ }
74
+
75
+ /** What one call of a compiled function is handed besides its own arguments. */
76
+ export interface FunctionCall {
77
+ /** The consumer's gated loader for this invocation; see {@link FunctionRunner.compile}. */
78
+ readonly importModule: (spec: string, options?: ImportCallOptions) => Promise<unknown>;
79
+ /** What {@link FunctionRunner.settings} returned at the start of this invocation. Absent ⇒ the runner
80
+ * applies its own defaults, as it must for a value it did not produce. */
81
+ readonly settings?: FunctionRunSettings;
82
+ /** What a body's own `fetch` and `WebSocket` should be: the consumer's, which put each origin they reach
83
+ * to a {@link PermissionGate}, as `importModule` puts each module. A runner binds them in place of the
84
+ * globals; absent, or a runner that does not, leaves the body the platform's. Their types are the
85
+ * platform's own, so nothing a body is checked against changes. */
86
+ readonly fetch?: typeof fetch;
87
+ readonly WebSocket?: typeof WebSocket;
88
+ }
89
+
30
90
  export interface FunctionRunner {
31
- /** Compile `body` as the body of an async function taking `params`. Throws on a syntax error. A call
32
- * stopped at the limit rejects with an error whose `code` is {@link FUNCTION_TIMEOUT}. */
33
- compile(params: readonly string[], body: string): (...args: unknown[]) => Promise<unknown>;
91
+ /**
92
+ * Compile `source` — a TypeScript expression evaluating to an async function — and return something
93
+ * that calls it. Throws on a syntax error. A call stopped at the limit rejects with an error whose
94
+ * `code` is {@link FUNCTION_TIMEOUT}.
95
+ *
96
+ * An EXPRESSION, complete with its own parameter list, rather than a body plus names to bind: a body is
97
+ * not valid alone — `return` at its top level is a syntax error — so every runner would have to wrap it
98
+ * before it could strip or parse it.
99
+ *
100
+ * TypeScript, not erased JavaScript, because erasing is part of running it.
101
+ *
102
+ * `call` is this one invocation's: the loader a body's `import(…)` should reach (the consumer's gated
103
+ * loader, which closes over that invocation) and the {@link FunctionRunSettings} the consumer read for
104
+ * it. A runner that rewrites `import(…)` resolves it to `importModule`; one that does not simply ignores
105
+ * it. How it gets into scope — a parameter the runner names itself, a closure — is internal, and nothing
106
+ * outside declares or sees it: the type-check runs on the source as the author wrote it, before any of
107
+ * this.
108
+ */
109
+ compile(source: string): (call: FunctionCall, ...args: unknown[]) => Promise<unknown>;
110
+ /**
111
+ * Read this runner's configuration for ONE invocation — a run, or a check — which the caller then holds
112
+ * for that invocation's whole extent and hands back on {@link FunctionCall.settings}.
113
+ *
114
+ * Read per invocation rather than once at boot, because whose settings they are is not something a
115
+ * runner can know: the settings store may be partitioned by principal or by nothing, which is the
116
+ * storage implementation's business. A boot read answered for the boot principal for ever after, and a
117
+ * per-principal cache would assume a partitioning nobody promised. An invocation, by contrast, has one
118
+ * principal throughout, so one read at its start is correct by construction, and every part of it — the
119
+ * gate, the type-check, the time bound — sees the same answer.
120
+ *
121
+ * Absent ⇒ nothing to configure: no restriction.
122
+ */
123
+ settings?(): Promise<FunctionRunSettings>;
124
+ /**
125
+ * Turn a granted specifier into a module — a builtin or an installed package, never a path or a URL
126
+ * (see {@link importableByName}). The runner's rather than a plain `import()` at the call site because
127
+ * where a package resolves FROM is the runner's business: node's resolves from the runner's own module.
128
+ * `options` is the import's second argument, passed through as written, so an import attribute
129
+ * (`{ with: { type: 'json' } }`) reaches the loader. Absent ⇒ the caller falls back to a dynamic import.
130
+ *
131
+ * It is reached only after the gate has allowed this specifier for this function, and it performs no
132
+ * check of its own — a capability, not a policy.
133
+ */
134
+ import?(spec: string, options?: ImportCallOptions): Promise<unknown>;
135
+ /**
136
+ * The spelling `spec` loads as, where the runner's platform has two for one module: node loads `os` as
137
+ * `node:os` whatever is installed, so asking about `os` and `node:os` separately asked twice about one
138
+ * module, and described a builtin as a package that "runs with matbot's full access" along with
139
+ * everything it imports. A consumer canonicalises BEFORE deciding, so one grant, one restriction and
140
+ * one label cover both spellings. Only a runner can answer, because the set of builtins is its
141
+ * platform's — `test` is a package to node, `node:test` the builtin.
142
+ *
143
+ * Absent ⇒ `spec` is taken as written.
144
+ */
145
+ canonicalImport?(spec: string): string;
146
+ /**
147
+ * Identifiers a body does NOT have — anything this runner withholds, shadows or never binds (node's
148
+ * withholds `process`, `globalThis`, `global`, `eval` and `Function`, and names the phantom
149
+ * `require`/`module`/`__dirname` too, which never existed in a bare function expression but whose node
150
+ * types would otherwise make one typecheck and then fail at the first call).
151
+ *
152
+ * Stated here because only the runner knows, and a checker grading a body must REJECT them: a name
153
+ * shadowed but not rejected typechecks and fails at the first call, which is the one failure a check
154
+ * gate exists to prevent. The second consumer of a list is why it is not kept privately, exactly as
155
+ * for {@link FunctionRunSettings.permittedImports}.
156
+ *
157
+ * Absent ⇒ nothing is claimed, not that nothing is withheld.
158
+ */
159
+ readonly withheldGlobals?: readonly string[];
34
160
  }
35
161
 
36
162
  /** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
37
163
  * consumer can tell a stopped runaway from an ordinary failure without matching message text. */
38
164
  export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
39
165
 
166
+
167
+ /**
168
+ * Whether `spec` names a module the way a `tool_function` body may import one: a node builtin (`node:fs`)
169
+ * or an installed package (`lodash`, `@scope/pkg/sub`). Never a path, a URL or any other scheme.
170
+ *
171
+ * Downloading or placing code is what a plugin install is for. It is reviewed and versioned, and it has
172
+ * its own gate. A body is too lightweight a thing to recommend it from. Paths are refused for a second
173
+ * reason: they defeat {@link importPermitted}, which matches strings, so `./lib/../../x` "starts with"
174
+ * `./lib/` and resolves outside it. A `.` or `..` segment in a package subpath is refused for the same
175
+ * reason, because a package with no `exports` map resolves it as a path.
176
+ *
177
+ * The characters are an ALLOWLIST, because the resolver is a URL parser and decides what a dot segment is
178
+ * by its own rules: `%2e%2e`, `.%2E` and `.<tab>.` are all `..` to it (the parser strips tabs and
179
+ * newlines), and each one escaped the package from `typescript/…/package.json`. Enumerating encodings
180
+ * is a race against that spec; refusing `%`, whitespace and anything outside a package name's alphabet
181
+ * is not. No npm name contains any of them, and a subpath that needs one is not worth the hole.
182
+ *
183
+ * Shared because three places must agree: the gate in `function-tools`, the runner, and the type-check.
184
+ */
185
+ export const importableByName = (spec: string): boolean =>
186
+ spec !== ''
187
+ && !/[^\w@.~+!$&'()*,;=:/-]/.test(spec)
188
+ && !/^[./]/.test(spec)
189
+ && (spec.startsWith('node:') || !/^[a-z][a-z0-9+.-]*:/i.test(spec))
190
+ && !spec.split('/').some(seg => seg === '.' || seg === '..');
191
+
192
+ /**
193
+ * Whether {@link FunctionRunSettings.permittedImports} admits `spec` — the boundary rule that interface
194
+ * documents, as a function, because two packages read the same list and must agree: `function-tools`
195
+ * decides whether to ask about an import, and `tool-types` decides which modules to TYPE. They had a copy
196
+ * each, and they drifted exactly where it hurts — `node:fs` admitted `node:fs/promises` at the gate while
197
+ * the checker declared only `node:fs`, so a permitted subpath failed the check and needed `noTypeCheck`
198
+ * to run at all.
199
+ *
200
+ * A prefix must end at a boundary: its own trailing `/` or `:`, or the next `/` in the specifier. A bare
201
+ * `startsWith` let an entry admit a sibling whose name merely begins the same way, which is the whole
202
+ * value of naming one. A specifier {@link importableByName} refuses is never admitted, whatever the list
203
+ * says, since string matching cannot see what `..` does.
204
+ */
205
+ export const importPermitted = (permits: readonly string[], spec: string): boolean =>
206
+ importableByName(spec) && permits.some(p => spec === p
207
+ || (p.endsWith('/') || p.endsWith(':') ? spec.startsWith(p) : spec.startsWith(`${p}/`)));
208
+
40
209
  /**
41
210
  * One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
42
211
  *
@@ -47,7 +216,8 @@ export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
47
216
  * flattened `string[]` made every consumer do.
48
217
  */
49
218
  export interface ToolCheckDiagnostic {
50
- /** The rule's one name, in every renderer: `TS2339`, or `CAST-GATE` for a structural cast-gate finding. */
219
+ /** The rule's one name, in every renderer: `TS2339`, or a structural rule's own name — `CAST-GATE`,
220
+ * `ENV-GATE` for a global the runner does not define. */
51
221
  label: string;
52
222
  /** The numeric code. Cast-gate findings use a private 9000x range — read {@link syn}, not the number. */
53
223
  code: number;
@@ -91,6 +261,13 @@ export interface ToolCheckReport {
91
261
  diagnostics: ToolCheckDiagnostic[];
92
262
  /** Present only when the cap hid some, tallied by the same `label` the detailed ones carry. */
93
263
  omitted?: { count: number; byLabel: Record<string, number> };
264
+ /**
265
+ * The {@link ToolTypeIndex.environment} fingerprint this verdict was reached under — what the check
266
+ * depended on besides the snippet and the live tool types. A caller that PERSISTS a verdict stores it
267
+ * beside the source and compares it (string equality, never parsed) against the index's current one,
268
+ * so "this passed" can say which environment it passed in. Absent when nothing was checked.
269
+ */
270
+ environment?: string;
94
271
  }
95
272
 
96
273
  /**
@@ -150,4 +327,23 @@ export interface ToolTypeIndex {
150
327
  * outgoing tool descriptions at the turn's dispatch edge. A tool with only a loose `inputSchema` (no
151
328
  * contract) is absent. */
152
329
  wireContracts(): Promise<Record<string, { params: string; result: string }>>;
330
+ /**
331
+ * A fingerprint of everything a {@link check} verdict depends on APART from the snippet and the live
332
+ * tool types — in practice the `FunctionRunner` that will run the code: whether there is one, what it
333
+ * permits a body to import, and which globals it does not bind. Opaque: a consumer compares it for
334
+ * equality and never parses it.
335
+ *
336
+ * It exists because a stored verdict is a claim about an environment, and the environment can be
337
+ * swapped out from under it — unload the runner plugin, or narrow its permitted imports, and a body
338
+ * that passed stays marked as having passed while its `import(…)` is no longer gated, or no longer
339
+ * allowed at all. The tool types are deliberately NOT in it: those are live and `check` is how a
340
+ * caller re-verifies against them, whereas this names the thing a re-check cannot be prompted by.
341
+ *
342
+ * Stable across restarts, so never identity-derived, and derived at the point of use like everything
343
+ * else read off the runner — there is nothing here to invalidate or subscribe to.
344
+ *
345
+ * Optional, because an index written before it existed must degrade rather than throw: a caller with
346
+ * no fingerprint stores none, and compares nothing.
347
+ */
348
+ environment?(): Promise<string>;
153
349
  }
@@ -1,13 +1,17 @@
1
1
  // ── Vault ─────────────────────────────────────────────────────────────────────
2
2
 
3
3
  /**
4
- * The primitives a vault backend must implement. Resolution, redaction, and the three
5
- * low-level store operations the smart `createSecret` policy composes. A backend implements
6
- * `Vault` (which is `VaultSpec` plus that policy); plugins are handed a `Vault`.
4
+ * The primitives a vault backend must implement. Redaction, and the low-level store operations the
5
+ * `resolve` and `createSecret` policies compose. A backend implements `Vault` (which is `VaultSpec`
6
+ * plus those policies); plugins are handed a `Vault`.
7
7
  */
8
8
  export interface VaultSpec {
9
- /** Resolve ${NAME} placeholders by looking up the named value; throws MissingSecretError for any miss. */
10
- resolve(ref: string): Promise<string>;
9
+ /**
10
+ * The value stored under exactly `name`, or `undefined`. The literal read, as `writeSecret` is the
11
+ * literal write: a name is a name, with no placeholder syntax, so a caller holding one never encodes
12
+ * it into a `${NAME}` for `resolve` to parse back out — `A}${B` is simply absent, not two lookups.
13
+ */
14
+ readSecret(name: string): Promise<string | undefined>;
11
15
  scrub(text: string): string;
12
16
  /**
13
17
  * Store `value` under exactly `name`, overwriting. The literal write; no reference/dedup logic.
@@ -37,6 +41,11 @@ export interface VaultSpec {
37
41
  }
38
42
 
39
43
  export interface Vault extends VaultSpec {
44
+ /**
45
+ * Expand every `${NAME}` placeholder in `ref` with its stored value; throws `MissingSecretError`
46
+ * naming every miss. A backend's is `applyResolve(this, ref)`.
47
+ */
48
+ resolve(ref: string): Promise<string>;
40
49
  /**
41
50
  * Store a secret coming (often) from a user via the LLM, where we cannot tell a real value
42
51
  * from a key name they typed by mistake. Returns the key name callers MUST reference — not
package/src/vault.ts CHANGED
@@ -1,5 +1,21 @@
1
1
  import type { VaultSpec } from './types.js';
2
- import { invalidSecretNameError } from './errors.js';
2
+ import { invalidSecretNameError, missingSecretError } from './errors.js';
3
+
4
+ const PLACEHOLDER = /\$\{([^}]+)\}/g;
5
+
6
+ /**
7
+ * The `resolve` policy, written once over `readSecret` so the placeholder grammar has one home — it was
8
+ * a regex and a replace loop copied into every backend, so a backend's idea of `${NAME}` could drift
9
+ * from the rule below that says which names can be stored. A backend's `resolve` is just
10
+ * `applyResolve(this, ref)`.
11
+ */
12
+ export async function applyResolve(spec: VaultSpec, ref: string): Promise<string> {
13
+ const names = [...new Set(Array.from(ref.matchAll(PLACEHOLDER), m => m[1] ?? ''))];
14
+ const values = new Map(await Promise.all(names.map(async n => [n, await spec.readSecret(n)] as const)));
15
+ const missing = names.filter(n => values.get(n) === undefined);
16
+ if (missing.length > 0) throw missingSecretError(missing);
17
+ return ref.replace(PLACEHOLDER, (_, name: string) => values.get(name) ?? '');
18
+ }
3
19
 
4
20
  /**
5
21
  * The rule every vault shares, whatever else it adds: a secret is only reachable through a