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.
Write the roster
Section titled “Write the roster”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.
Withhold a capability
Section titled “Withhold a capability”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(); }}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",);node --import tsx bookmarks/run-read-only.tsRefused: ShiitakeError: Extension is unavailablereadOnlySave 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.
Narrow a granted tool
Section titled “Narrow a granted tool”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.
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(); }}import { readGrantAdd } from "./read-grant.js";
console.log(JSON.stringify(await readGrantAdd(), null, 2));node --import tsx bookmarks/run-read-grant.tsbookmarks.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.