@fungi.computer/mycelium 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +303 -2
- package/THIRD_PARTY_NOTICES +2 -0
- package/dist/access.d.ts +129 -0
- package/dist/access.js +166 -0
- package/dist/capability-source.d.ts +44 -0
- package/dist/capability-source.js +1 -0
- package/dist/compiler.d.ts +28 -0
- package/dist/compiler.js +221 -0
- package/dist/define.d.ts +8 -0
- package/dist/define.js +7 -0
- package/dist/effect.d.ts +89 -0
- package/dist/effect.js +42 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.js +62 -0
- package/dist/execute-tool.d.ts +22 -0
- package/dist/execute-tool.js +163 -0
- package/dist/image-collector.d.ts +47 -0
- package/dist/image-collector.js +39 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +22 -0
- package/dist/internal/build-identity.d.ts +1 -0
- package/dist/internal/build-identity.js +15 -0
- package/dist/internal/build-identity.node.d.ts +1 -0
- package/dist/internal/build-identity.node.js +42 -0
- package/dist/internal/execution-lifecycle.d.ts +44 -0
- package/dist/internal/execution-lifecycle.js +328 -0
- package/dist/internal/mcp-principal.d.ts +14 -0
- package/dist/internal/mcp-principal.js +17 -0
- package/dist/mcp-catalog.d.ts +23 -0
- package/dist/mcp-catalog.js +110 -0
- package/dist/mcp.d.ts +92 -0
- package/dist/mcp.js +250 -0
- package/dist/operation-schema.d.ts +47 -0
- package/dist/operation-schema.js +50 -0
- package/dist/promise-facade.d.ts +3 -0
- package/dist/promise-facade.js +82 -0
- package/dist/registration.d.ts +74 -0
- package/dist/registration.js +157 -0
- package/dist/remote.d.ts +33 -0
- package/dist/remote.js +126 -0
- package/dist/runtime.d.ts +51 -0
- package/dist/runtime.js +159 -0
- package/dist/sandbox-boundary.d.ts +44 -0
- package/dist/sandbox-boundary.js +325 -0
- package/dist/sandbox-failure-event.d.ts +15 -0
- package/dist/sandbox-failure-event.js +34 -0
- package/dist/sandbox-failure.d.ts +128 -0
- package/dist/sandbox-failure.js +161 -0
- package/dist/sandbox-guest.d.ts +34 -0
- package/dist/sandbox-guest.js +131 -0
- package/dist/sandbox-wire.d.ts +76 -0
- package/dist/sandbox-wire.js +190 -0
- package/dist/sandbox.d.ts +41 -0
- package/dist/sandbox.js +1 -0
- package/dist/semaphore-transition.d.ts +7 -0
- package/dist/semaphore-transition.js +9 -0
- package/dist/source-runtime.d.ts +28 -0
- package/dist/source-runtime.js +162 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.js +19 -0
- package/dist/workers/index.d.ts +9 -0
- package/dist/workers/index.js +90 -0
- package/docs/api/README.md +25 -0
- package/docs/api/define.md +20 -0
- package/docs/api/effect.md +245 -0
- package/docs/api/index.md +1081 -0
- package/docs/api/mcp.md +239 -0
- package/docs/api/remote.md +108 -0
- package/docs/api/sandbox-boundary.md +325 -0
- package/docs/api/sandbox-failure.md +1133 -0
- package/docs/api/sandbox-guest.md +191 -0
- package/docs/api/sandbox-wire.md +117 -0
- package/docs/api/sandbox.md +81 -0
- package/docs/api/semaphore-transition.md +40 -0
- package/docs/api/testing.md +72 -0
- package/docs/api/workers.md +55 -0
- package/package.json +132 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,304 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mycelium (`@fungi.computer/mycelium`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Mycelium gives an Agent typed operations through one `execute` tool. It
|
|
4
|
+
validates operations, acquires a coherent capability snapshot and releases it
|
|
5
|
+
when the run ends. You can also execute a known operation without a model.
|
|
6
|
+
|
|
7
|
+
## Set up
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @fungi.computer/mycelium
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package ships ESM only and depends on Zod, Effect, `acorn`,
|
|
14
|
+
[Cairn](../cairn/README.md), [Forage](../forage/README.md), and
|
|
15
|
+
[Mule](../mule/README.md) at runtime, all installed automatically. The root
|
|
16
|
+
entrypoint is the Promise host facade. `@fungi.computer/mycelium/effect`
|
|
17
|
+
supplies the scoped Effect API. Both produce an Effect-native Mule tool. The
|
|
18
|
+
`./sandbox-guest`, `./sandbox-wire`, `./sandbox-failure`, `./sandbox-boundary`,
|
|
19
|
+
and `./semaphore-transition` subpaths support a host's sandbox adapter. A host
|
|
20
|
+
supplies the sandbox that actually evaluates code and enforces isolation,
|
|
21
|
+
external access and cancellation.
|
|
22
|
+
|
|
23
|
+
## Quick start without a model
|
|
24
|
+
|
|
25
|
+
This example registers `demo.ping`, executes one known binding and prints
|
|
26
|
+
`{"value":"pong"}`. The direct callback deliberately ignores submitted code. It
|
|
27
|
+
is **not isolation**: it runs with host privileges and checks registration,
|
|
28
|
+
validation and cleanup.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Mycelium } from "@fungi.computer/mycelium";
|
|
32
|
+
import { directSandbox } from "@fungi.computer/mycelium/testing";
|
|
33
|
+
import * as Effect from "effect/Effect";
|
|
34
|
+
import * as Stream from "effect/Stream";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
|
|
37
|
+
const runtime = await Mycelium.make({
|
|
38
|
+
modules: [
|
|
39
|
+
Mycelium.module({
|
|
40
|
+
id: "example-demo",
|
|
41
|
+
name: "demo",
|
|
42
|
+
operations: {
|
|
43
|
+
ping: Mycelium.operation({
|
|
44
|
+
input: z.object({}),
|
|
45
|
+
output: z.object({ value: z.string() }),
|
|
46
|
+
execute: async () => ({ value: "pong" }),
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
sandbox: directSandbox((bindings, signal) => {
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
const ping = bindings.demo?.ping;
|
|
54
|
+
if (ping === undefined) throw new Error("Missing demo.ping binding");
|
|
55
|
+
return ping({});
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const lease = await runtime.acquire();
|
|
59
|
+
try {
|
|
60
|
+
const prepared = await Effect.runPromise(
|
|
61
|
+
lease.executeTool.prepare({
|
|
62
|
+
type: "toolCall",
|
|
63
|
+
id: "example-execute",
|
|
64
|
+
name: "execute",
|
|
65
|
+
arguments: { code: "return demo.ping({})" },
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const parts = await Effect.runPromise(Stream.runCollect(prepared.execute()));
|
|
69
|
+
const text = parts[0]?.result.content[0];
|
|
70
|
+
if (text?.type !== "text" || text.text !== '{"value":"pong"}') {
|
|
71
|
+
throw new Error("Unexpected execute result");
|
|
72
|
+
}
|
|
73
|
+
console.log(text.text);
|
|
74
|
+
} finally {
|
|
75
|
+
await lease.release();
|
|
76
|
+
await runtime.close();
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The [public-import fixture](test/stranger/runtime.mjs) uses the same
|
|
81
|
+
known-binding pattern. A real evaluator must implement the public sandbox
|
|
82
|
+
contract. Its request carries code, execution identity, limits and admitted
|
|
83
|
+
bindings. Do not replace it with evaluation in the host's privileged process.
|
|
84
|
+
|
|
85
|
+
## Operations and discovery
|
|
86
|
+
|
|
87
|
+
Declare input and output with an Effect schema or any Standard Schema that also
|
|
88
|
+
implements Standard JSON Schema, such as Zod 4. Mycelium decodes input with the
|
|
89
|
+
schema before the operation runs. An Effect output schema encodes the result (so
|
|
90
|
+
a codec such as `Schema.NumberFromString` returns `"1"`); a Standard output
|
|
91
|
+
schema validates it. An Effect schema is exchanged as its canonical JSON codec
|
|
92
|
+
(`Schema.toCodecJson`), so the description, the decoder and the encoder agree. A
|
|
93
|
+
throwing filter or transformation is a host error, never reported to the model
|
|
94
|
+
as invalid input. Stable module IDs identify authority. Names become
|
|
95
|
+
model-facing aliases. Input ordering does not choose either identity. Forage
|
|
96
|
+
prepares the bounded discovery catalog for the captured graph.
|
|
97
|
+
|
|
98
|
+
Mule receives this runtime's one top-level `execute` tool. Inner operations,
|
|
99
|
+
including `forage.search` and `forage.describe`, stay behind that tool. The
|
|
100
|
+
operation callback receives only its declared input and execution context.
|
|
101
|
+
Access checks and authorization gates run before the operation. Product
|
|
102
|
+
membership, resource ownership and final external access checks remain host
|
|
103
|
+
responsibilities.
|
|
104
|
+
|
|
105
|
+
## Access labels and grants
|
|
106
|
+
|
|
107
|
+
Each operation's author labels what it does: `access: "read"` observes,
|
|
108
|
+
`access: "write"` changes something, and an unlabelled operation is `write`. An
|
|
109
|
+
author may add `confirm: true` to ask a person before each call. Labels are
|
|
110
|
+
fixed facts about the operation; the host never edits them.
|
|
111
|
+
|
|
112
|
+
The host passes its decisions down as data in `access`. `grants` maps a module
|
|
113
|
+
id to `"write"` (every operation), `"read"` (only `read` operations) or
|
|
114
|
+
`"none"`. A module the host does not name keeps full access, so a host that
|
|
115
|
+
grants nothing writes no permission code. `sessionGrants` reads the current
|
|
116
|
+
Session's stored grants at every call; they override `grants` key by key, so a
|
|
117
|
+
revoked grant applies to the very next call. Keys in `required` fail closed:
|
|
118
|
+
when neither names one, it is `"none"`. Labels are parsed at definition with
|
|
119
|
+
`ToolPolicySchema`: anything but exactly `"read"`/`"write"` and a boolean
|
|
120
|
+
`confirm` is a definition error. `confirm` is how the host asks a person;
|
|
121
|
+
without it, a `confirm` operation is refused and never runs unasked.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const runtime = await Mycelium.make({
|
|
125
|
+
modules: [notes],
|
|
126
|
+
sandbox,
|
|
127
|
+
access: {
|
|
128
|
+
grants: { notes: "read" },
|
|
129
|
+
confirm: (request) => askPerson(request),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Mycelium checks the grant first, then the person, before the operation's input
|
|
135
|
+
decoder runs. A refused call returns an `AccessRefusal` (`module`, `operation`,
|
|
136
|
+
`reason` and a plain `message`) as its ordinary result. `onRefused` observes
|
|
137
|
+
each refusal for logs or metering and never changes the decision.
|
|
138
|
+
`Mycelium.grantAs` looks a module's grant up by a wider owner's key, such as the
|
|
139
|
+
extension that built it. Labelled direct tools passed as `tools` sit behind the
|
|
140
|
+
same check and come back on the lease's `tools`.
|
|
141
|
+
[Access tests](test/access.test.ts) cover each rule.
|
|
142
|
+
|
|
143
|
+
## Nested namespaces
|
|
144
|
+
|
|
145
|
+
Compose modules in `operations` to expose any namespace depth. The enclosing
|
|
146
|
+
module ID remains the authority identity; child registration IDs do not create
|
|
147
|
+
new authority. For example, a module named `bazaar` with `namespace: ["apps"]`
|
|
148
|
+
and `operations: { windows: windowModule }` exposes the child's `open` operation
|
|
149
|
+
as `env.apps.bazaar.windows.open(...)`. A child's own `namespace` adds segments
|
|
150
|
+
after its registration key. Omit `namespace` for the ordinary depth-1 form
|
|
151
|
+
`env.demo.ping(...)`; the existing `demo.ping(...)` form also works.
|
|
152
|
+
|
|
153
|
+
> An operation key may also be a dotted relative path:
|
|
154
|
+
> `operations: { "repos.list": op }` is called as
|
|
155
|
+
> `env.<module>.repos.list(...)`, exactly like a child module `repos` with
|
|
156
|
+
> operation `list`. Installation refuses a key that is both an operation and a
|
|
157
|
+
> namespace prefix (`files` with `files.read`).
|
|
158
|
+
|
|
159
|
+
Every leaf keeps its declared input/output types. A gate on the enclosing module
|
|
160
|
+
sees relative paths such as `windows.open`. Forage search, list and describe use
|
|
161
|
+
the complete logical path, and descriptions declare the same nested, callable
|
|
162
|
+
`env`. Names are allocated within their logical namespace, so an App and a
|
|
163
|
+
Computer can share a name without renaming either. Transport aliases and flat
|
|
164
|
+
operation tables remain host-only implementation details. Sandbox adapters use
|
|
165
|
+
`/sandbox-guest`'s parsed namespace metadata and `sandboxGuestProgram` to
|
|
166
|
+
project those bindings; provider adapters also use
|
|
167
|
+
`sandboxProviderOperationName` to avoid Codemode's dotted-name collisions.
|
|
168
|
+
|
|
169
|
+
## Changing capabilities and lifetime
|
|
170
|
+
|
|
171
|
+
Use `sources` when a host must acquire a current Computer, Connection or other
|
|
172
|
+
capability projection. A source returns its bounded snapshot and an awaited
|
|
173
|
+
release function. Its `invalidate()` callback marks that snapshot stale. The
|
|
174
|
+
next lease builds a new coherent graph. An existing lease keeps its captured
|
|
175
|
+
snapshot.
|
|
176
|
+
|
|
177
|
+
Release each lease and close the runtime. Partial acquisition unwinds already
|
|
178
|
+
acquired sources in reverse order. Mycelium owns the scoped resources it
|
|
179
|
+
acquires, while the host owns durable relationships, credentials and current
|
|
180
|
+
policy. A captured snapshot does not excuse a missing final authority check.
|
|
181
|
+
|
|
182
|
+
## Cancellation and errors
|
|
183
|
+
|
|
184
|
+
Execution has a deadline, a cancellation acknowledgement grace and a bounded
|
|
185
|
+
JSON result. Cancellation aborts the invocation signal and waits for backing
|
|
186
|
+
sandbox settlement. If the sandbox ignores it, the result reports
|
|
187
|
+
`acknowledged: false`. Do not report that work as stopped.
|
|
188
|
+
|
|
189
|
+
The typed failure channel includes syntax errors, guest exceptions, lost
|
|
190
|
+
transport, bad responses, failed capabilities, deadline/cancellation and invalid
|
|
191
|
+
or oversized results. A foreign throw becomes `Unknown`. The model receives the
|
|
192
|
+
bounded `sandboxFailureMessage` projection with a reference. The host receives
|
|
193
|
+
operator evidence through `onSandboxFailure` and the existing reporting path.
|
|
194
|
+
Preserve that evidence without sending host diagnostics to the guest.
|
|
195
|
+
|
|
196
|
+
An Effect host with a Promise sandbox uses `fromTypedSandbox`, which waits for
|
|
197
|
+
that Promise to settle when interrupted. The concrete sandbox and broker decide
|
|
198
|
+
how backing work is stopped. Hosts supply the Worker Loader binding and
|
|
199
|
+
credential custody. The Workers-only `@fungi.computer/mycelium/workers` entry
|
|
200
|
+
exports `createCodemodeSandbox(loader, adapter?, options?)`, a Codemode adapter
|
|
201
|
+
runnable on Workers and celld. It disables guest network access, binds only the
|
|
202
|
+
supplied operations, preserves typed failures and awaits the backing execution
|
|
203
|
+
when cancellation cannot be acknowledged. Import this entry only in Workers
|
|
204
|
+
hosts; the root and other Node entries do not load it.
|
|
205
|
+
|
|
206
|
+
The adapter forwards the request's existing `timeoutMs` budget as the Worker
|
|
207
|
+
Loader's `cpuMs` limit, retaining any tighter loader limit. Cloudflare and celld
|
|
208
|
+
enforce native CPU limits. Local workerd development does not; execution still
|
|
209
|
+
runs. The local host declares that fact with
|
|
210
|
+
`{ localDevUnenforcedLimits: ["cpu"] }` as the third argument. Mycelium prints
|
|
211
|
+
`CPU limits are not enforced in local dev` once per runtime, across sandbox
|
|
212
|
+
instances. Hosts may also declare unenforced `memory`, `output` and `cancel`
|
|
213
|
+
controls; each gets its own once-only diagnostic. The option reports host facts;
|
|
214
|
+
it adds no enforcement or guest policy. Cancellation still waits for backing
|
|
215
|
+
settlement, and a wall-time timeout does not prove isolate termination.
|
|
216
|
+
|
|
217
|
+
The
|
|
218
|
+
[Worker Loader custom-limit contract](https://developers.cloudflare.com/dynamic-workers/usage/limits/)
|
|
219
|
+
and the pinned celld invocation types expose `cpuMs` and `subRequests`. They do
|
|
220
|
+
not expose per-invocation memory or output caps, or an execution-cancel
|
|
221
|
+
operation. The adapter preserves supported loader limits without inventing
|
|
222
|
+
fields for those controls. Host-wide memory limits remain host configuration;
|
|
223
|
+
they are not a guest-widenable sandbox setting. The canonical Mycelium
|
|
224
|
+
invocation owner checks its JSON result budget; calling the bare `Sandbox`
|
|
225
|
+
interface does not run that owner's result validation.
|
|
226
|
+
|
|
227
|
+
An abort before dispatch starts no guest. An abort during execution remains
|
|
228
|
+
authoritative after backing settlement: the adapter's Promise rejects with typed
|
|
229
|
+
`Cancelled { by: "caller" }` instead of resolving a late success. Hosts retain
|
|
230
|
+
invocation custody until that Promise settles; an abort does not release custody
|
|
231
|
+
early. Codemode has no immediate cancellation API. The native qualification
|
|
232
|
+
checks this behavior through the real Worker Loader, along with the trusted
|
|
233
|
+
deadline, CPU forwarding and once-only local notices. These are sandbox checks;
|
|
234
|
+
they do not prove a Workflow host's cancellation path.
|
|
235
|
+
|
|
236
|
+
## API and examples
|
|
237
|
+
|
|
238
|
+
Use the [generated API reference](docs/api/README.md) and
|
|
239
|
+
[exports](package.json). Root and `/effect` expose lifecycle construction. The
|
|
240
|
+
sandbox subpaths expose wire, guest, failure and boundary contracts for
|
|
241
|
+
adapters.
|
|
242
|
+
|
|
243
|
+
[Capability lifecycle tests](test/capability-plane.test.ts) cover source updates
|
|
244
|
+
and release. [Gate tests](test/gate.test.ts) exercise refusals.
|
|
245
|
+
[Sandbox boundary tests](test/sandbox-boundary.test.ts) exercise failure
|
|
246
|
+
projection. These package proofs do not qualify a particular sandbox's isolation
|
|
247
|
+
or deployment.
|
|
248
|
+
|
|
249
|
+
## Contribute and license
|
|
250
|
+
|
|
251
|
+
Add a regression to the nearest capability, gate or sandbox test. From the root:
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
pnpm --dir packages/mycelium run test
|
|
255
|
+
pnpm --dir packages/mycelium run docs
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
MIT. See [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
|
|
259
|
+
|
|
260
|
+
## MCP
|
|
261
|
+
|
|
262
|
+
`@fungi.computer/mycelium/mcp` exports `createMcp({ catalog, authenticate })`,
|
|
263
|
+
returning `{ handleRequest, close }`. Mount `handleRequest` at the host's MCP
|
|
264
|
+
route. Authentication runs for every HTTP request, including notifications and
|
|
265
|
+
disconnects, and before each operation. Return either
|
|
266
|
+
`{ kind: "admitted", principal }` or `{ kind: "refused", status: 401 | 403 }`.
|
|
267
|
+
The principal is the host's canonical actor: a member with its role
|
|
268
|
+
(`{ actor: { kind: "member", memberId }, role }`), or an Agent or Team key
|
|
269
|
+
(`{ actor: { kind: "agent", agentId } }`,
|
|
270
|
+
`{ actor: { kind: "team-key", keyId } }`). Its ids identify the credential's
|
|
271
|
+
actor, never a bearer token.
|
|
272
|
+
|
|
273
|
+
For installed-App hosts, `createMcpCatalog({ load, sandbox })` adapts ordinary
|
|
274
|
+
Mycelium capability sources. `load(principal)` returns the current
|
|
275
|
+
`{ instructions, sources, grants }` authorized for that principal. It runs again
|
|
276
|
+
before execution and after approval. Each invocation must begin a fresh
|
|
277
|
+
authority read; returning an older cached snapshot cannot establish current
|
|
278
|
+
grants. Grants are explicit: an omitted module is refused. The supplied sandbox
|
|
279
|
+
remains the host's isolated executor; this entry provides no host-fetch
|
|
280
|
+
fallback. This catalog keeps at most eight open connections in process memory
|
|
281
|
+
and releases each acquired tool runtime when its request finishes.
|
|
282
|
+
|
|
283
|
+
Durable Agent hosts implement `McpCatalogProvider` over their existing Session
|
|
284
|
+
owner. `open` atomically reserves the supplied connection bound, `resume`
|
|
285
|
+
returns only an open principal-bound Session, and `refresh` delivers current
|
|
286
|
+
grants. The MCP server independently checks the stored principal. The Session's
|
|
287
|
+
`call` must carry `McpExecutionContext.access` into its ordinary Mycelium
|
|
288
|
+
execution, including direct tools, without persisting request callbacks.
|
|
289
|
+
|
|
290
|
+
MCP execution requires approval for every write-level operation not covered by
|
|
291
|
+
the catalog snapshot's `approvedWrites`, including unlabelled operations. Read
|
|
292
|
+
operations retain their declared confirmation policy. `approvedWrites` names
|
|
293
|
+
granted modules whose writes a person already approved for that principal; it is
|
|
294
|
+
read from the current snapshot at each call, and an operation labelled `confirm`
|
|
295
|
+
still asks. Form elicitation runs before the operation, and authority is
|
|
296
|
+
refreshed again after acceptance; missing or declined approval refuses
|
|
297
|
+
execution. The server supports MCP `2025-11-25` Streamable HTTP so an approval
|
|
298
|
+
can suspend the existing execution without replaying already-completed
|
|
299
|
+
operations. Approval requests contain the module, operation and access label,
|
|
300
|
+
not credential data or operation arguments. `close()` closes transport streams;
|
|
301
|
+
Session retention and connection persistence remain with the catalog owner.
|
|
302
|
+
After transport process loss, a resumed connection has no recorded client
|
|
303
|
+
elicitation capability and refuses writes. Delete that connection and initialize
|
|
304
|
+
a fresh one to negotiate form elicitation again.
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { MuleTool } from "@fungi.computer/mule/effect";
|
|
2
|
+
import type { SandboxValue } from "./index.js";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
/**
|
|
5
|
+
* What one operation does, declared by its author as a fixed fact: `read`
|
|
6
|
+
* observes, `write` changes something. An unlabelled operation is `write`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ToolAccessSchema: Schema.Literals<readonly ["read", "write"]>;
|
|
9
|
+
/** What one operation does: `read` observes, `write` changes something. */
|
|
10
|
+
export type Access = typeof ToolAccessSchema.Type;
|
|
11
|
+
/** Whether the author asks that a person approve each call first. */
|
|
12
|
+
export declare const ToolConfirmSchema: Schema.Boolean;
|
|
13
|
+
/**
|
|
14
|
+
* An author's labels for one operation or direct tool: the one definition
|
|
15
|
+
* both the TypeScript type and the registration-time parse derive from.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ToolPolicySchema: Schema.Struct<{
|
|
18
|
+
readonly access: Schema.optional<Schema.Literals<readonly ["read", "write"]>>;
|
|
19
|
+
readonly confirm: Schema.optional<Schema.Boolean>;
|
|
20
|
+
}>;
|
|
21
|
+
/** An author's labels for one operation or direct tool. */
|
|
22
|
+
export type ToolPolicy = typeof ToolPolicySchema.Type;
|
|
23
|
+
/**
|
|
24
|
+
* The access a host grants one module: `write` allows every operation,
|
|
25
|
+
* `read` allows only `read` operations, `none` allows nothing.
|
|
26
|
+
*/
|
|
27
|
+
export declare const GrantSchema: Schema.Literals<readonly ["none", "read", "write"]>;
|
|
28
|
+
/** The access a host grants one module. */
|
|
29
|
+
export type Grant = typeof GrantSchema.Type;
|
|
30
|
+
/**
|
|
31
|
+
* A host's grants, keyed by module id. An unnamed module uses defaultGrant
|
|
32
|
+
* unless it is required, in which case missing authority is refused.
|
|
33
|
+
*/
|
|
34
|
+
export type Grants = Readonly<Record<string, Grant>>;
|
|
35
|
+
/** The labels an operation or direct tool carries, as its author declared them. */
|
|
36
|
+
export type AccessLabel = Readonly<{
|
|
37
|
+
/** Stable module id the grant is looked up by. */
|
|
38
|
+
module: string;
|
|
39
|
+
/** Operation path inside the module, or the direct tool's name. */
|
|
40
|
+
operation: string;
|
|
41
|
+
access: Access;
|
|
42
|
+
/** The author asks that a person approve each call first. */
|
|
43
|
+
confirm: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
/** The Session a runtime serves; null for a session-free host. */
|
|
46
|
+
export type AccessContext = Readonly<{
|
|
47
|
+
sessionId: string | null;
|
|
48
|
+
}>;
|
|
49
|
+
/** One call the host is asked to approve before it runs. */
|
|
50
|
+
export type ConfirmRequest = Readonly<Omit<AccessLabel, "confirm"> & AccessContext & {
|
|
51
|
+
/** The requested input exactly as the caller supplied it, not yet decoded. */
|
|
52
|
+
input: SandboxValue;
|
|
53
|
+
}>;
|
|
54
|
+
/** Why a call was refused before it ran. */
|
|
55
|
+
export type AccessRefusalReason = "not-granted" | "read-only" | "confirmation-unavailable" | "declined";
|
|
56
|
+
/** The ordinary result a refused call returns in place of running. */
|
|
57
|
+
export type AccessRefusal = Readonly<{
|
|
58
|
+
refused: true;
|
|
59
|
+
module: string;
|
|
60
|
+
operation: string;
|
|
61
|
+
reason: AccessRefusalReason;
|
|
62
|
+
message: string;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* Host-supplied access data and the one way to ask a person. Every field is
|
|
66
|
+
* optional: hosted defaults allow unnamed modules, and no `confirm` means an
|
|
67
|
+
* operation requiring approval is refused, never run unasked. MCP catalogs
|
|
68
|
+
* explicitly default unnamed modules to none and require approval for writes.
|
|
69
|
+
*/
|
|
70
|
+
export type AccessOptions = Readonly<{
|
|
71
|
+
/** Grant for unnamed modules; MCP catalogs use none. Defaults to write for hosted callers. */
|
|
72
|
+
defaultGrant?: Grant;
|
|
73
|
+
/** The default grants as plain data; absent is full access. */
|
|
74
|
+
grants?: Grants;
|
|
75
|
+
/**
|
|
76
|
+
* Module keys under grant control. A required key that neither the Session
|
|
77
|
+
* grants nor the default names is refused, so a missing delivery fails
|
|
78
|
+
* closed. Other unnamed keys use defaultGrant.
|
|
79
|
+
*/
|
|
80
|
+
required?: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Read the current Session's stored grants at every call; they override
|
|
83
|
+
* `grants` key by key, so a revoked grant applies to the very next call.
|
|
84
|
+
*/
|
|
85
|
+
sessionGrants?: (context: AccessContext) => Grants;
|
|
86
|
+
/** Refresh authority before a call and again after approval. Failure refuses execution. */
|
|
87
|
+
beforeCall?: (signal: AbortSignal) => Promise<void>;
|
|
88
|
+
/** Require approval for every write, including operations without an explicit confirm label. */
|
|
89
|
+
confirmWrites?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Read, at every call, the module ids whose writes a person already
|
|
92
|
+
* approved for this caller. Such a write needs no per-call `confirm`; an
|
|
93
|
+
* operation its author labelled `confirm` still asks, and grants still apply.
|
|
94
|
+
*/
|
|
95
|
+
approvedWrites?: (context: AccessContext) => readonly string[];
|
|
96
|
+
/** Ask a person to approve one call; resolve true to run it. */
|
|
97
|
+
confirm?: ((request: ConfirmRequest, signal: AbortSignal) => Promise<boolean>) | undefined;
|
|
98
|
+
/** Operator-only observer of every refusal; never changes the decision. */
|
|
99
|
+
onRefused?: (refusal: AccessRefusal, context: AccessContext) => void;
|
|
100
|
+
}>;
|
|
101
|
+
/** A Mule tool that carries its author's access labels outside execute. */
|
|
102
|
+
export type LabelledTool = MuleTool & Readonly<{
|
|
103
|
+
/** Stable module id the grant is looked up by. */
|
|
104
|
+
module: string;
|
|
105
|
+
/** Unlabelled means `write`. */
|
|
106
|
+
access?: Access;
|
|
107
|
+
/** Ask a person before each call. */
|
|
108
|
+
confirm?: boolean;
|
|
109
|
+
}>;
|
|
110
|
+
/**
|
|
111
|
+
* Parse an author's labels at definition. Only the exact `"read"` and
|
|
112
|
+
* `"write"` and a boolean `confirm` are labels; anything else is a definition
|
|
113
|
+
* error, never quietly read as a weaker label.
|
|
114
|
+
*/
|
|
115
|
+
export declare function parseAccessLabels(labels: Readonly<{
|
|
116
|
+
access?: SandboxValue;
|
|
117
|
+
confirm?: SandboxValue;
|
|
118
|
+
}>): Pick<AccessLabel, "access" | "confirm">;
|
|
119
|
+
/**
|
|
120
|
+
* Decide one call against its labels and the host's current grants: the grant
|
|
121
|
+
* first, then the person. Returns the refusal, or undefined to run the call.
|
|
122
|
+
*/
|
|
123
|
+
export declare function checkAccess(options: AccessOptions, context: AccessContext, label: AccessLabel, input: SandboxValue, signal: AbortSignal): Promise<AccessRefusal | undefined>;
|
|
124
|
+
/**
|
|
125
|
+
* Put one labelled direct tool behind the host's access options. A refused
|
|
126
|
+
* call never reaches the tool's decoder and returns the refusal as its
|
|
127
|
+
* ordinary result, as a refused operation does inside execute.
|
|
128
|
+
*/
|
|
129
|
+
export declare function guardTool(tool: LabelledTool, options: AccessOptions, context: AccessContext): MuleTool;
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { MyceliumDefinitionError } from "./errors.js";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Result from "effect/Result";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Stream from "effect/Stream";
|
|
6
|
+
/**
|
|
7
|
+
* What one operation does, declared by its author as a fixed fact: `read`
|
|
8
|
+
* observes, `write` changes something. An unlabelled operation is `write`.
|
|
9
|
+
*/
|
|
10
|
+
export const ToolAccessSchema = Schema.Literals(["read", "write"]);
|
|
11
|
+
/** Whether the author asks that a person approve each call first. */
|
|
12
|
+
export const ToolConfirmSchema = Schema.Boolean;
|
|
13
|
+
/**
|
|
14
|
+
* An author's labels for one operation or direct tool: the one definition
|
|
15
|
+
* both the TypeScript type and the registration-time parse derive from.
|
|
16
|
+
*/
|
|
17
|
+
export const ToolPolicySchema = Schema.Struct({
|
|
18
|
+
access: Schema.optional(ToolAccessSchema),
|
|
19
|
+
confirm: Schema.optional(ToolConfirmSchema),
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* The access a host grants one module: `write` allows every operation,
|
|
23
|
+
* `read` allows only `read` operations, `none` allows nothing.
|
|
24
|
+
*/
|
|
25
|
+
export const GrantSchema = Schema.Literals(["none", "read", "write"]);
|
|
26
|
+
const decodeToolPolicy = Schema.decodeUnknownResult(ToolPolicySchema);
|
|
27
|
+
const decodeGrant = Schema.decodeUnknownResult(GrantSchema);
|
|
28
|
+
/**
|
|
29
|
+
* Parse an author's labels at definition. Only the exact `"read"` and
|
|
30
|
+
* `"write"` and a boolean `confirm` are labels; anything else is a definition
|
|
31
|
+
* error, never quietly read as a weaker label.
|
|
32
|
+
*/
|
|
33
|
+
export function parseAccessLabels(labels) {
|
|
34
|
+
const parsed = decodeToolPolicy({
|
|
35
|
+
access: labels.access,
|
|
36
|
+
confirm: labels.confirm,
|
|
37
|
+
});
|
|
38
|
+
if (Result.isFailure(parsed))
|
|
39
|
+
throw new MyceliumDefinitionError('Access labels must be access: "read" | "write" and confirm: boolean');
|
|
40
|
+
return {
|
|
41
|
+
access: parsed.success.access ?? "write",
|
|
42
|
+
confirm: parsed.success.confirm ?? false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function messageFor(reason, label) {
|
|
46
|
+
const name = `${label.module}.${label.operation}`;
|
|
47
|
+
switch (reason) {
|
|
48
|
+
case "not-granted":
|
|
49
|
+
return `${name} is not granted to this Agent.`;
|
|
50
|
+
case "read-only":
|
|
51
|
+
return `${name} changes something, and this Agent has read-only access to ${label.module}.`;
|
|
52
|
+
case "confirmation-unavailable":
|
|
53
|
+
return `${name} needs a person's approval, and no one can be asked here.`;
|
|
54
|
+
case "declined":
|
|
55
|
+
return `${name} was not approved.`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function refusal(reason, label) {
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
refused: true,
|
|
61
|
+
module: label.module,
|
|
62
|
+
operation: label.operation,
|
|
63
|
+
reason,
|
|
64
|
+
message: messageFor(reason, label),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The grant for one module. An unnamed module uses the configured default
|
|
69
|
+
* unless it is required; a present entry that is not a grant refuses, so a
|
|
70
|
+
* malformed host value fails closed.
|
|
71
|
+
*/
|
|
72
|
+
function grantFor(options, context, module) {
|
|
73
|
+
const session = options.sessionGrants?.(context);
|
|
74
|
+
const owner = session !== undefined && Object.hasOwn(session, module)
|
|
75
|
+
? session
|
|
76
|
+
: options.grants;
|
|
77
|
+
if (owner === undefined || !Object.hasOwn(owner, module))
|
|
78
|
+
return options.required?.includes(module) === true
|
|
79
|
+
? "none"
|
|
80
|
+
: (options.defaultGrant ?? "write");
|
|
81
|
+
const parsed = decodeGrant(owner[module]);
|
|
82
|
+
return Result.isSuccess(parsed) ? parsed.success : "none";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decide one call against its labels and the host's current grants: the grant
|
|
86
|
+
* first, then the person. Returns the refusal, or undefined to run the call.
|
|
87
|
+
*/
|
|
88
|
+
export async function checkAccess(options, context, label, input, signal) {
|
|
89
|
+
const decided = await decide(options, context, label, input, signal);
|
|
90
|
+
if (decided !== undefined)
|
|
91
|
+
options.onRefused?.(decided, context);
|
|
92
|
+
return decided;
|
|
93
|
+
}
|
|
94
|
+
async function decide(options, context, label, input, signal) {
|
|
95
|
+
await options.beforeCall?.(signal);
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const granted = grantRefusal(options, context, label);
|
|
98
|
+
if (granted !== undefined)
|
|
99
|
+
return granted;
|
|
100
|
+
if (!label.confirm &&
|
|
101
|
+
!(options.confirmWrites === true && label.access !== "read"))
|
|
102
|
+
return undefined;
|
|
103
|
+
if (!label.confirm &&
|
|
104
|
+
options.approvedWrites?.(context).includes(label.module) === true)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (options.confirm === undefined)
|
|
107
|
+
return refusal("confirmation-unavailable", label);
|
|
108
|
+
const approved = await options.confirm({
|
|
109
|
+
module: label.module,
|
|
110
|
+
operation: label.operation,
|
|
111
|
+
access: label.access,
|
|
112
|
+
sessionId: context.sessionId,
|
|
113
|
+
input,
|
|
114
|
+
}, signal);
|
|
115
|
+
if (!approved)
|
|
116
|
+
return refusal("declined", label);
|
|
117
|
+
await options.beforeCall?.(signal);
|
|
118
|
+
signal.throwIfAborted();
|
|
119
|
+
// A grant revoked while the person was deciding still applies.
|
|
120
|
+
return grantRefusal(options, context, label);
|
|
121
|
+
}
|
|
122
|
+
/** The refusal the current grant gives this label, if any. */
|
|
123
|
+
function grantRefusal(options, context, label) {
|
|
124
|
+
const grant = grantFor(options, context, label.module);
|
|
125
|
+
if (grant === "none")
|
|
126
|
+
return refusal("not-granted", label);
|
|
127
|
+
// Only an exact "read" label is a read; every other operation writes.
|
|
128
|
+
if (grant === "read" && label.access !== "read")
|
|
129
|
+
return refusal("read-only", label);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Put one labelled direct tool behind the host's access options. A refused
|
|
134
|
+
* call never reaches the tool's decoder and returns the refusal as its
|
|
135
|
+
* ordinary result, as a refused operation does inside execute.
|
|
136
|
+
*/
|
|
137
|
+
export function guardTool(tool, options, context) {
|
|
138
|
+
const label = {
|
|
139
|
+
module: tool.module,
|
|
140
|
+
operation: tool.descriptor.name,
|
|
141
|
+
...parseAccessLabels(tool),
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
...tool,
|
|
145
|
+
prepare: (call) => Effect.tryPromise({
|
|
146
|
+
try: (signal) => checkAccess(options, context, label, call.arguments, signal),
|
|
147
|
+
// Host failures stay out of the model's view; the call fails closed.
|
|
148
|
+
catch: () => ({ message: "Access check failed" }),
|
|
149
|
+
}).pipe(Effect.flatMap((refused) => refused === undefined
|
|
150
|
+
? tool.prepare(call)
|
|
151
|
+
: Effect.succeed({
|
|
152
|
+
execute: () => Stream.succeed({
|
|
153
|
+
type: "result",
|
|
154
|
+
result: {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: JSON.stringify(refused),
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
isError: false,
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
}))),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type * as Schema from "effect/Schema";
|
|
2
|
+
import type { ModuleRegistration } from "./registration.js";
|
|
3
|
+
/** Session on whose behalf one runtime acquires its capability projections. */
|
|
4
|
+
export type CapabilitySession = Readonly<{
|
|
5
|
+
/** Stable Session identity supplied by the session host (Shiitake). */
|
|
6
|
+
sessionId: string;
|
|
7
|
+
/** Host's stored command identity; stable across run recovery. */
|
|
8
|
+
commandId?: string;
|
|
9
|
+
/** Opaque host metadata from that stored command, never a model argument. */
|
|
10
|
+
meta?: typeof Schema.Json.Type;
|
|
11
|
+
}>;
|
|
12
|
+
/** Context supplied while acquiring one current capability projection. */
|
|
13
|
+
export type CapabilitySourceContext = Readonly<{
|
|
14
|
+
/**
|
|
15
|
+
* Session the projection is acquired for; absent for session-free hosts.
|
|
16
|
+
* A source uses it to project only what that Session is permitted to use.
|
|
17
|
+
*/
|
|
18
|
+
session?: CapabilitySession;
|
|
19
|
+
/** Cooperative cancellation for this bounded acquisition attempt. */
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
/** Mark the candidate stale when the source's current projection changes. */
|
|
22
|
+
invalidate: () => void;
|
|
23
|
+
}>;
|
|
24
|
+
/** Bounded current projection returned by one capability source. */
|
|
25
|
+
export type CapabilitySourceSnapshot = Readonly<{
|
|
26
|
+
/** Stable, secret-free version of this source projection. */
|
|
27
|
+
version: string;
|
|
28
|
+
/** Unordered modules contributed by this projection. */
|
|
29
|
+
modules: readonly ModuleRegistration[];
|
|
30
|
+
}>;
|
|
31
|
+
/** Acquired source projection and the cleanup that owns its live adapter. */
|
|
32
|
+
export type CapabilitySourceAcquisition = Readonly<{
|
|
33
|
+
/** Current immutable projection captured by this acquisition. */
|
|
34
|
+
snapshot: CapabilitySourceSnapshot;
|
|
35
|
+
/** Release subscriptions and adapters owned by this acquisition. */
|
|
36
|
+
release: () => Promise<void>;
|
|
37
|
+
}>;
|
|
38
|
+
/** Promise-shaped host boundary for one versioned capability projection. */
|
|
39
|
+
export type CapabilitySource = Readonly<{
|
|
40
|
+
/** Stable, bounded, secret-free source identity. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** Acquire one current projection and its awaited cleanup. */
|
|
43
|
+
acquire: (context: CapabilitySourceContext) => Promise<CapabilitySourceAcquisition>;
|
|
44
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|