Compare commits

...
Author SHA1 Message Date
LukeParkerDev 9cd2559b24 feat(plugin): hide tools per request with a snapshot hook 2026-09-07 08:12:09 +10:00
7 changed files with 144 additions and 55 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(agent.info.permissions, { sessionID, agent: agent.id }),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
+61 -53
View File
@@ -39,7 +39,11 @@ type Data = {
}
export interface Interface extends State.Transformable<Editor> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
/** Filters by permissions, then lets `tool.snapshot` hooks hide tools for the given request. */
readonly snapshot: (
permissions?: Permission.Ruleset,
request?: { readonly sessionID: SessionSchema.ID; readonly agent: Agent.ID },
) => Effect.Effect<Snapshot>
}
/** A local execution result after hooks and content normalization. */
@@ -217,58 +221,62 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codeModeTool ? [definition(codeModeTool)] : []),
],
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
const event = yield* beforeExecute(input.call.name, input.call.input, context)
const requested = input.definitions?.get(event.tool)
// Preserve session context removal and alias resolution, now after the repair hook.
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
const name = requested?.name ?? event.tool
if (name === "execute" && codeModeTool)
return yield* executeTool(codeModeTool, name, event.input, context)
const tool = direct.get(name)
if (tool) return yield* executeTool(tool, name, event.input, context)
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
}),
}
}),
),
snapshot: Effect.fn("Tool.snapshot")(function* (permissions, request) {
const rules = permissions ?? []
const permitted = new Map(
Array.from(state.get().tools).filter(
([name, tool]) => !whollyDisabled(tool.options?.permission ?? name, rules),
),
)
// Hooks see only permitted names and can only remove them, so a plugin cannot reveal a denied tool.
const visible = request
? new Set(
(yield* hooks.trigger("tool", "snapshot", { ...request, tools: Array.from(permitted.keys()) })).tools,
)
: undefined
const active = visible ? new Map(Array.from(permitted).filter(([name]) => visible.has(name))) : permitted
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codeModeTool ? [definition(codeModeTool)] : []),
],
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
const event = yield* beforeExecute(input.call.name, input.call.input, context)
const requested = input.definitions?.get(event.tool)
// Preserve session context removal and alias resolution, now after the repair hook.
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
const name = requested?.name ?? event.tool
if (name === "execute" && codeModeTool) return yield* executeTool(codeModeTool, name, event.input, context)
const tool = direct.get(name)
if (tool) return yield* executeTool(tool, name, event.input, context)
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
}),
}
}),
})
}),
)
+5 -1
View File
@@ -53,6 +53,10 @@ The registry has no `Permission.Service` dependency and performs no execution au
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
## Per-request visibility
`Tool.Service.snapshot(permissions, request)` triggers the `tool.snapshot` plugin hook after permission filtering, passing the session and agent plus the permitted effective names. Hooks can only remove names; added names are ignored, so a plugin cannot reveal a denied tool. The result filters both the native definitions and the Code Mode inventory, so the rendered catalog and the `execute` runtime stay consistent. Registration remains Location-wide; this hook is how availability varies per session, such as a client connected to one session.
## Output
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
@@ -61,4 +65,4 @@ Producer capture remains local to producers. Shell stores combined process outpu
## Current Gaps
- Future Session-scoped registrations still need an explicit canonical registration design.
- Future Session-scoped registrations still need an explicit canonical registration design. The `tool.snapshot` hook covers per-session visibility of Location-wide registrations, not per-session definitions.
+43
View File
@@ -706,6 +706,49 @@ describe("Tool", () => {
}),
)
it.effect("hides tools per request through the snapshot hook after permission filtering", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { question: make(), edit: make() }, { codemode: false })
yield* transform(service, { open: make(), list: make() }, { namespace: "browser" })
const seen: string[][] = []
const hidden = Session.ID.make("ses_without_browser")
yield* hooks.register("tool", "snapshot", (event) =>
Effect.sync(() => {
seen.push(event.tools)
event.tools = [...event.tools.filter((name) => !name.startsWith("browser_")), "invented"]
if (event.sessionID !== hidden) event.tools.push("browser_open")
}),
)
const names = (snapshot: Tool.Snapshot) => ({
direct: snapshot.definitions.map((tool) => tool.name),
codemode: codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path),
})
const attached = yield* service.snapshot([{ action: "edit", resource: "*", effect: "deny" }], {
sessionID,
agent: identity.agent,
})
expect(seen).toEqual([["question", "browser_open", "browser_list"]])
expect(names(attached)).toEqual({ direct: ["question", "execute"], codemode: ["browser.open"] })
const detached = yield* service.snapshot(undefined, { sessionID: hidden, agent: identity.agent })
expect(names(detached)).toEqual({ direct: ["edit", "question", "execute"], codemode: [] })
expect((yield* detached.execute(call("edit"))).output).toEqual({ text: "edit" })
const result = yield* detached.execute({
...call("execute"),
call: { type: "tool-call", id: "hidden-codemode", name: "execute", input: { code: "return tools.browser" } },
})
expect(result.output).toMatchObject({ error: true })
expect(names(yield* service.snapshot())).toEqual({
direct: ["edit", "question", "execute"],
codemode: ["browser.list", "browser.open"],
})
}),
)
it.effect("keeps permission options isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+7
View File
@@ -18,6 +18,12 @@ export interface ToolEditor {
}
export interface ToolHooks {
readonly snapshot: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
/** Effective tool names advertised to this request. Remove names to hide tools; added names are ignored. */
tools: string[]
}
readonly "execute.before": {
tool: string
readonly sessionID: Session.ID
@@ -47,6 +53,7 @@ export interface ToolHooks {
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly snapshot: never
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
+6
View File
@@ -35,6 +35,12 @@ export interface ToolEditor {
}
interface ToolHooks {
readonly snapshot: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
/** Effective tool names advertised to this request. Remove names to hide tools; added names are ignored. */
tools: string[]
}
readonly "execute.before": {
tool: string
readonly sessionID: Session.ID
@@ -1438,6 +1438,20 @@ interface ShellCreateBefore {
### Tools
Hide tools from one request. The hook runs when a model request captures its tool
snapshot and receives the effective tool names permitted for that session and agent.
Removing a name hides the tool from both the native tool list and the Code Mode catalog.
```ts
await ctx.tool.hook("snapshot", (event) => {
if (!browsers.has(event.sessionID)) event.tools = event.tools.filter((name) => !name.startsWith("browser_"))
})
```
- Names added by a hook are ignored; a hook cannot reveal a tool that permissions removed.
- Registration stays location-wide. Use this hook when availability depends on the session, such as a connected client.
- Hiding is catalog visibility, not authorization. A tool still enforces its own permission when it runs.
Inspect or replace tool input before execution.
```ts
@@ -1459,10 +1473,17 @@ await ctx.tool.hook("execute.after", (event) => {
```ts
interface ToolHooks {
snapshot: ToolSnapshot
"execute.before": ToolExecuteBefore
"execute.after": ToolExecuteCompleted | ToolExecuteFailed
}
interface ToolSnapshot {
readonly sessionID: string
readonly agent: string
tools: string[]
}
interface ToolHookContext {
hook<Name extends keyof ToolHooks>(
name: Name,