Skip to content
Write an extension

Grant only what it needs

Separate what an extension asks for from what a host allows.

An extension requests capabilities by what it defines: prompt for prompt sections, state for collections, commands, run_tools for tools, projection, and so on. A host grants some or all of them. Until now the test host granted everything each extension asked for. A real host decides.

bookmarks/roster.ts
import type { ExtensionRosterV0 } from "@fungi.computer/shiitake/extensions";
import { bookmarks } from "./extension.js";
/** The host's decision: this roster entry, and exactly these capabilities. */
export const bookmarkRoster: ExtensionRosterV0 = {
roster: [bookmarks],
grants: [
{
id: bookmarks.manifest.id,
capabilities: ["state", "prompt", "commands", "run_tools", "projection"],
},
],
};

A roster lists the extensions a host loads and the capabilities it grants to each. Pass it as the extensions option of Shiitake.make, or of memoryHost when you want a test to use the real grants. Shiitake refuses a grant the extension never requested, and a grant for an extension that is not on the roster.

bookmarks/grants.ts
import type { ExtensionRosterV0 } from "@fungi.computer/shiitake/extensions";
import { memoryHost } from "@fungi.computer/shiitake/testing";
import { bookmarks } from "./extension.js";
/** A host that lets clients read bookmarks but nobody save them. */
export const readOnlyRoster: ExtensionRosterV0 = {
roster: [bookmarks],
grants: [
{
id: bookmarks.manifest.id,
capabilities: ["state", "prompt", "projection"],
},
],
};
/** A person tries to save a link on the read-only host. */
export async function readOnlySave() {
const host = await memoryHost({ extensions: readOnlyRoster });
try {
const session = host.agent.session("reading");
await session.send("Start.", { requestId: "start" });
await host.settle("reading");
return await session.extension(bookmarks.manifest.id).call("add", {
url: "https://fungi.computer/",
title: "Fungi",
});
} finally {
await host.close();
}
}
bookmarks/run-read-only.ts
import { readOnlySave } from "./grants.js";
const [outcome] = await Promise.allSettled([readOnlySave()]);
console.log(
outcome.status === "rejected"
? `Refused: ${String(outcome.reason)}`
: "Saved, which this host should have refused",
);
TERMINAL
node --import tsx bookmarks/run-read-only.ts
TEXT
Refused: ShiitakeError: Extension is unavailable

readOnlySave asks the read-only host to save a link through the command. Without commands, that call is refused. Without run_tools, the model is never offered bookmarks.add; a model that calls it anyway gets an error and nothing is saved. The extension’s code did not change. Removing a grant removes the authority; do not re-check grants inside the extension.

A capability grant decides whether an extension’s tools exist at all. Inside them, each operation’s author labels what it does, access: "read" or the default "write", and may add confirm: true to ask a person first. The host decides how much of that a Session may use and hands its decision down as data: tools.access.grants is the Agent-wide default, and agent.grants.deliver(sessionId, { version, grants }) delivers one Session’s grants. An extension’s tools are granted by its id; "read" runs only read operations and "none" runs nothing. Keys in access.required are refused unless a grant names them, so a Session nobody delivered to fails closed.

bookmarks/read-grant.ts
import {
fauxAssistantMessage,
fauxToolCall,
} from "@earendil-works/pi-ai/providers/faux";
import type { SandboxValue } from "@fungi.computer/mycelium";
import { directSandbox } from "@fungi.computer/mycelium/testing";
import { fauxModelSource, memoryHost } from "@fungi.computer/shiitake/testing";
import { bookmarkContract } from "./contract.js";
import { bookmarkRoster } from "./roster.js";
/**
* The host grants this Session read-only use of Bookmarks' tools, and the
* model tries to save a link. `bookmarks.add` changes something, so it is
* refused before it runs; the refusal comes back as the call's result.
*/
export async function readGrantAdd(): Promise<SandboxValue> {
const results: SandboxValue[] = [];
const host = await memoryHost({
extensions: bookmarkRoster,
model: fauxModelSource([
fauxAssistantMessage(fauxToolCall("execute", { code: "bookmarks.add" }), {
stopReason: "toolUse",
}),
fauxAssistantMessage("I could not save it."),
]),
tools: {
// A trusted test sandbox that makes exactly the model's one call.
sandbox: directSandbox(async (bindings) => {
const add = bindings.bookmarks?.add;
if (add === undefined) throw new Error("bookmarks.add is not offered");
results.push(
await add({ url: "https://fungi.computer/", title: "Fungi" }),
);
return null;
}),
// Every Bookmarks tool needs a grant; a Session without one gets none.
access: { required: [bookmarkContract.id] },
},
});
try {
// The host's own decision, delivered as versioned data for one Session.
host.agent.grants.deliver("reading", {
version: 1,
grants: { [bookmarkContract.id]: "read" },
});
await host.agent.session("reading").send("Save Fungi.", {
requestId: "save",
});
await host.settle("reading");
return results[0] ?? null;
} finally {
await host.close();
}
}
bookmarks/run-read-grant.ts
import { readGrantAdd } from "./read-grant.js";
console.log(JSON.stringify(await readGrantAdd(), null, 2));
TERMINAL
node --import tsx bookmarks/run-read-grant.ts

bookmarks.add is unlabelled, so it is a write; under a read grant it never runs, and the model gets a refusal that says why. Mycelium makes this decision at every call, so a grant the host delivers mid-run applies to the very next call. The extension still does not check grants itself.

Next: Test it.