@matatbread/matbot-plugin-api 0.4.19 → 0.4.20

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.20",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/hooks.ts CHANGED
@@ -7,6 +7,12 @@ import { withUsageSite } from './usage-context.js';
7
7
 
8
8
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
9
9
 
10
+ // A turn tool is never registered, so `register` never stamps its plugin — and that stamp is what a
11
+ // `ctx.gate` namespace is checked against. The hook that offered it IS stamped, so its tools take that,
12
+ // and a `pluginName` the source wrote itself is dropped: the claim would otherwise be the plugin's own word.
13
+ const ownedBy = (source: TurnToolSource, owner: string | undefined): TurnToolSource => async session =>
14
+ (await source(session)).map(({ pluginName: _, ...tool }) => owner !== undefined ? { ...tool, pluginName: owner } : tool);
15
+
10
16
  export class HookRegistry implements HookRegistrar {
11
17
  private readonly hooks = new Map<HookPoint, Hook[]>();
12
18
 
@@ -156,7 +162,7 @@ export class HookRegistry implements HookRegistrar {
156
162
  if (r.durable && r.durable.length) foldDurable(r.durable);
157
163
  if (r.markers && r.markers.length) appendMarkers(r.markers);
158
164
  if (r.deferred) deferred.push(r.deferred);
159
- if (r.tools) tools.push(r.tools);
165
+ if (r.tools) tools.push(ownedBy(r.tools, hook.pluginName));
160
166
  if (r.abort) {
161
167
  const drained = this.drainFailureMarkers(session);
162
168
  return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools, abort: r.abort };
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';
@@ -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,18 +33,33 @@ 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
+ return (gate ?? askPermissionGate).decide({ ...req, gate: `${namespace ?? name}.${req.gate}` }, ask);
62
+ },
48
63
  };
49
64
  }
50
65
 
@@ -97,7 +112,7 @@ export function invokeTool<K extends string, const P>(
97
112
  // The raw `opts.prompt`, not the rejecting stand-in above: a gate must be able to tell "nobody is
98
113
  // here" from "a human answered", and `POST /tools/:name` reaching a privileged tool is exactly the
99
114
  // non-interactive case the request's `fallback` exists to answer.
100
- ...bindGate(machine.PermissionGate, name, opts.prompt),
115
+ ...bindGate(machine.PermissionGate, tool, opts.prompt, machine.tools),
101
116
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
102
117
  ...(opts.turnTools !== undefined ? { turnTools: opts.turnTools } : {}),
103
118
  ...(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
  };
@@ -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 }
@@ -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
  /**
@@ -42,5 +123,5 @@ export interface PermissionRequest {
42
123
  * delegates to it (the `ToolCallValidator` idiom), 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
 
@@ -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. */
@@ -18,25 +18,188 @@ 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
+ }
83
+
30
84
  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>;
85
+ /**
86
+ * Compile `source` — a TypeScript expression evaluating to an async function — and return something
87
+ * that calls it. Throws on a syntax error. A call stopped at the limit rejects with an error whose
88
+ * `code` is {@link FUNCTION_TIMEOUT}.
89
+ *
90
+ * An EXPRESSION, complete with its own parameter list, rather than a body plus names to bind: a body is
91
+ * not valid alone — `return` at its top level is a syntax error — so every runner would have to wrap it
92
+ * before it could strip or parse it.
93
+ *
94
+ * TypeScript, not erased JavaScript, because erasing is part of running it.
95
+ *
96
+ * `call` is this one invocation's: the loader a body's `import(…)` should reach (the consumer's gated
97
+ * loader, which closes over that invocation) and the {@link FunctionRunSettings} the consumer read for
98
+ * it. A runner that rewrites `import(…)` resolves it to `importModule`; one that does not simply ignores
99
+ * it. How it gets into scope — a parameter the runner names itself, a closure — is internal, and nothing
100
+ * outside declares or sees it: the type-check runs on the source as the author wrote it, before any of
101
+ * this.
102
+ */
103
+ compile(source: string): (call: FunctionCall, ...args: unknown[]) => Promise<unknown>;
104
+ /**
105
+ * Read this runner's configuration for ONE invocation — a run, or a check — which the caller then holds
106
+ * for that invocation's whole extent and hands back on {@link FunctionCall.settings}.
107
+ *
108
+ * Read per invocation rather than once at boot, because whose settings they are is not something a
109
+ * runner can know: the settings store may be partitioned by principal or by nothing, which is the
110
+ * storage implementation's business. A boot read answered for the boot principal for ever after, and a
111
+ * per-principal cache would assume a partitioning nobody promised. An invocation, by contrast, has one
112
+ * principal throughout, so one read at its start is correct by construction, and every part of it — the
113
+ * gate, the type-check, the time bound — sees the same answer.
114
+ *
115
+ * Absent ⇒ nothing to configure: no restriction.
116
+ */
117
+ settings?(): Promise<FunctionRunSettings>;
118
+ /**
119
+ * Turn a granted specifier into a module — a builtin or an installed package, never a path or a URL
120
+ * (see {@link importableByName}). The runner's rather than a plain `import()` at the call site because
121
+ * where a package resolves FROM is the runner's business: node's resolves from the runner's own module.
122
+ * `options` is the import's second argument, passed through as written, so an import attribute
123
+ * (`{ with: { type: 'json' } }`) reaches the loader. Absent ⇒ the caller falls back to a dynamic import.
124
+ *
125
+ * It is reached only after the gate has allowed this specifier for this function, and it performs no
126
+ * check of its own — a capability, not a policy.
127
+ */
128
+ import?(spec: string, options?: ImportCallOptions): Promise<unknown>;
129
+ /**
130
+ * The spelling `spec` loads as, where the runner's platform has two for one module: node loads `os` as
131
+ * `node:os` whatever is installed, so asking about `os` and `node:os` separately asked twice about one
132
+ * module, and described a builtin as a package that "runs with matbot's full access" along with
133
+ * everything it imports. A consumer canonicalises BEFORE deciding, so one grant, one restriction and
134
+ * one label cover both spellings. Only a runner can answer, because the set of builtins is its
135
+ * platform's — `test` is a package to node, `node:test` the builtin.
136
+ *
137
+ * Absent ⇒ `spec` is taken as written.
138
+ */
139
+ canonicalImport?(spec: string): string;
140
+ /**
141
+ * Identifiers a body does NOT have — anything this runner withholds, shadows or never binds (node's
142
+ * withholds `process`, `globalThis`, `Buffer`, `eval` and `Function`, and names the phantom
143
+ * `require`/`module`/`__dirname` too, which never existed in a bare function expression but whose node
144
+ * types would otherwise make one typecheck and then fail at the first call).
145
+ *
146
+ * Stated here because only the runner knows, and a checker grading a body must REJECT them: a name
147
+ * shadowed but not rejected typechecks and fails at the first call, which is the one failure a check
148
+ * gate exists to prevent. The second consumer of a list is why it is not kept privately, exactly as
149
+ * for {@link FunctionRunSettings.permittedImports}.
150
+ *
151
+ * Absent ⇒ nothing is claimed, not that nothing is withheld.
152
+ */
153
+ readonly withheldGlobals?: readonly string[];
34
154
  }
35
155
 
36
156
  /** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
37
157
  * consumer can tell a stopped runaway from an ordinary failure without matching message text. */
38
158
  export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
39
159
 
160
+
161
+ /**
162
+ * Whether `spec` names a module the way a `tool_function` body may import one: a node builtin (`node:fs`)
163
+ * or an installed package (`lodash`, `@scope/pkg/sub`). Never a path, a URL or any other scheme.
164
+ *
165
+ * Downloading or placing code is what a plugin install is for. It is reviewed and versioned, and it has
166
+ * its own gate. A body is too lightweight a thing to recommend it from. Paths are refused for a second
167
+ * reason: they defeat {@link importPermitted}, which matches strings, so `./lib/../../x` "starts with"
168
+ * `./lib/` and resolves outside it. A `.` or `..` segment in a package subpath is refused for the same
169
+ * reason, because a package with no `exports` map resolves it as a path.
170
+ *
171
+ * The characters are an ALLOWLIST, because the resolver is a URL parser and decides what a dot segment is
172
+ * by its own rules: `%2e%2e`, `.%2E` and `.<tab>.` are all `..` to it (the parser strips tabs and
173
+ * newlines), and each one escaped the package from `typescript/…/package.json`. Enumerating encodings
174
+ * is a race against that spec; refusing `%`, whitespace and anything outside a package name's alphabet
175
+ * is not. No npm name contains any of them, and a subpath that needs one is not worth the hole.
176
+ *
177
+ * Shared because three places must agree: the gate in `function-tools`, the runner, and the type-check.
178
+ */
179
+ export const importableByName = (spec: string): boolean =>
180
+ spec !== ''
181
+ && !/[^\w@.~+!$&'()*,;=:/-]/.test(spec)
182
+ && !/^[./]/.test(spec)
183
+ && (spec.startsWith('node:') || !/^[a-z][a-z0-9+.-]*:/i.test(spec))
184
+ && !spec.split('/').some(seg => seg === '.' || seg === '..');
185
+
186
+ /**
187
+ * Whether {@link FunctionRunSettings.permittedImports} admits `spec` — the boundary rule that interface
188
+ * documents, as a function, because two packages read the same list and must agree: `function-tools`
189
+ * decides whether to ask about an import, and `tool-types` decides which modules to TYPE. They had a copy
190
+ * each, and they drifted exactly where it hurts — `node:fs` admitted `node:fs/promises` at the gate while
191
+ * the checker declared only `node:fs`, so a permitted subpath failed the check and needed `noTypeCheck`
192
+ * to run at all.
193
+ *
194
+ * A prefix must end at a boundary: its own trailing `/` or `:`, or the next `/` in the specifier. A bare
195
+ * `startsWith` let an entry admit a sibling whose name merely begins the same way, which is the whole
196
+ * value of naming one. A specifier {@link importableByName} refuses is never admitted, whatever the list
197
+ * says, since string matching cannot see what `..` does.
198
+ */
199
+ export const importPermitted = (permits: readonly string[], spec: string): boolean =>
200
+ importableByName(spec) && permits.some(p => spec === p
201
+ || (p.endsWith('/') || p.endsWith(':') ? spec.startsWith(p) : spec.startsWith(`${p}/`)));
202
+
40
203
  /**
41
204
  * One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
42
205
  *
@@ -47,7 +210,8 @@ export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
47
210
  * flattened `string[]` made every consumer do.
48
211
  */
49
212
  export interface ToolCheckDiagnostic {
50
- /** The rule's one name, in every renderer: `TS2339`, or `CAST-GATE` for a structural cast-gate finding. */
213
+ /** The rule's one name, in every renderer: `TS2339`, or a structural rule's own name — `CAST-GATE`,
214
+ * `ENV-GATE` for a global the runner does not define. */
51
215
  label: string;
52
216
  /** The numeric code. Cast-gate findings use a private 9000x range — read {@link syn}, not the number. */
53
217
  code: number;
@@ -91,6 +255,13 @@ export interface ToolCheckReport {
91
255
  diagnostics: ToolCheckDiagnostic[];
92
256
  /** Present only when the cap hid some, tallied by the same `label` the detailed ones carry. */
93
257
  omitted?: { count: number; byLabel: Record<string, number> };
258
+ /**
259
+ * The {@link ToolTypeIndex.environment} fingerprint this verdict was reached under — what the check
260
+ * depended on besides the snippet and the live tool types. A caller that PERSISTS a verdict stores it
261
+ * beside the source and compares it (string equality, never parsed) against the index's current one,
262
+ * so "this passed" can say which environment it passed in. Absent when nothing was checked.
263
+ */
264
+ environment?: string;
94
265
  }
95
266
 
96
267
  /**
@@ -150,4 +321,23 @@ export interface ToolTypeIndex {
150
321
  * outgoing tool descriptions at the turn's dispatch edge. A tool with only a loose `inputSchema` (no
151
322
  * contract) is absent. */
152
323
  wireContracts(): Promise<Record<string, { params: string; result: string }>>;
324
+ /**
325
+ * A fingerprint of everything a {@link check} verdict depends on APART from the snippet and the live
326
+ * tool types — in practice the `FunctionRunner` that will run the code: whether there is one, what it
327
+ * permits a body to import, and which globals it does not bind. Opaque: a consumer compares it for
328
+ * equality and never parses it.
329
+ *
330
+ * It exists because a stored verdict is a claim about an environment, and the environment can be
331
+ * swapped out from under it — unload the runner plugin, or narrow its permitted imports, and a body
332
+ * that passed stays marked as having passed while its `import(…)` is no longer gated, or no longer
333
+ * allowed at all. The tool types are deliberately NOT in it: those are live and `check` is how a
334
+ * caller re-verifies against them, whereas this names the thing a re-check cannot be prompted by.
335
+ *
336
+ * Stable across restarts, so never identity-derived, and derived at the point of use like everything
337
+ * else read off the runner — there is nothing here to invalidate or subscribe to.
338
+ *
339
+ * Optional, because an index written before it existed must degrade rather than throw: a caller with
340
+ * no fingerprint stores none, and compares nothing.
341
+ */
342
+ environment?(): Promise<string>;
153
343
  }
@@ -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