@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 +1 -1
- package/src/hooks.ts +7 -1
- package/src/index.ts +1 -1
- package/src/invoke-tool.ts +21 -6
- package/src/permission-gate.ts +6 -3
- package/src/types/events.ts +3 -0
- package/src/types/permission.ts +82 -1
- package/src/types/provider.ts +8 -0
- package/src/types/tools.ts +14 -4
- package/src/types/typescript.ts +201 -11
- package/src/types/vault.ts +14 -5
- package/src/vault.ts +17 -1
package/package.json
CHANGED
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';
|
package/src/invoke-tool.ts
CHANGED
|
@@ -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:
|
|
43
|
-
|
|
44
|
-
ask:
|
|
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:
|
|
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,
|
|
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 } : {}),
|
package/src/permission-gate.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
const yes = (await ask(field)).trim().toLowerCase() === CONFIRM_YES;
|
|
25
|
+
return yes ? permitted('user') : refused('user');
|
|
23
26
|
},
|
|
24
27
|
};
|
package/src/types/events.ts
CHANGED
|
@@ -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 }
|
package/src/types/permission.ts
CHANGED
|
@@ -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<
|
|
126
|
+
decide(req: PermissionRequest, ask: PromptFn | undefined): Promise<PermissionDecision>;
|
|
46
127
|
}
|
package/src/types/provider.ts
CHANGED
|
@@ -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
|
|
package/src/types/tools.ts
CHANGED
|
@@ -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.
|
|
234
|
-
*
|
|
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<
|
|
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. */
|
package/src/types/typescript.ts
CHANGED
|
@@ -18,25 +18,188 @@ export interface TypeScriptStripper {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Optional, host-supplied:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
|
|
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
|
|
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
|
}
|
package/src/types/vault.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// ── Vault ─────────────────────────────────────────────────────────────────────
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The primitives a vault backend must implement.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
10
|
-
|
|
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
|