From a2eb50622a2b6bd6159ed67739fc107afe0a5175 Mon Sep 17 00:00:00 2001 From: Aleksandr Markov Date: Sat, 3 Oct 2026 13:53:01 +0200 Subject: [PATCH 1/8] fix(tools): enforce registered agent allowlists before dispatch Co-Authored-By: GPT-6.1 Sol (Codex) --- changelog.d/agent-allowed-tools.fixed.md | 1 + src/framework.ts | 37 ++- test/agent-allowed-tools.test.ts | 285 +++++++++++++++++++++++ 3 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 changelog.d/agent-allowed-tools.fixed.md create mode 100644 test/agent-allowed-tools.test.ts diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md new file mode 100644 index 000000000..f4593d159 --- /dev/null +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -0,0 +1 @@ +- Enforce registered agents' allowedTools before model, script, and public agent-origin tool dispatch, with paired error results and refusal tracing. Denied tools cannot silence or privatize public prose. Preserve trusted module dispatch, unregistered legacy ephemeral callers, and the implicit prose_help tool for explicit prose agents. diff --git a/src/framework.ts b/src/framework.ts index 25cdb419b..365891f5c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9587,7 +9587,9 @@ export class AgentFramework { // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). if (this.channelRegistry) { - const roundToolNames = event.calls.map((c) => c.name); + const roundToolNames = event.calls + .filter((c) => this.canAgentUseTool(agent.name, c.name)) + .map((c) => c.name); const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; @@ -10133,7 +10135,7 @@ export class AgentFramework { const toolNames = response.content .filter((b) => b.type === 'tool_use') .map((b) => (b as unknown as { name?: string }).name) - .filter((n): n is string => typeof n === 'string'); + .filter((n): n is string => typeof n === 'string' && this.canAgentUseTool(agent.name, n)); const silenced = liveProseRouting ? turnSilenced : turnSilenced || toolNames.some(isSilencingTool); @@ -10893,6 +10895,11 @@ export class AgentFramework { } private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { + if (origin.kind === 'agent') { + const refusal = this.agentToolPermissionFailure(origin.agentName, call); + if (refusal) return refusal; + } + // Client-side programmatic tool calling for promise-based callers // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral // gets its own interpreter state. @@ -11964,7 +11971,33 @@ export class AgentFramework { return best; } + /** Registered agent policy, including the implicit explicit-prose helper. + * Unregistered legacy ephemerals retain their full programmatic surface. */ + private canAgentUseTool(agentName: string, toolName: string): boolean { + const agent = this.agents.get(agentName); + return !agent || agent.canUseTool(toolName) || + (toolName === 'prose_help' && agent.proseRouting === 'explicit'); + } + + private agentToolPermissionFailure(agentName: string, call: ToolCall): ToolResult | undefined { + if (this.canAgentUseTool(agentName, call.name)) return undefined; + const error = `Tool '${call.name}' is not permitted by this agent's allowedTools.`; + this.toolLifecycleEmitter?.refuse(agentName, call.id); + this.emitTrace({ + type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error, + }); + return { success: false, error, isError: true }; + } + private dispatchToolCall(agentName: string, call: ToolCall): void { + const refusal = this.agentToolPermissionFailure(agentName, call); + if (refusal) { + this.pushEvent({ + type: 'tool-result', callId: call.id, agentName, moduleName: 'framework', result: refusal, + }); + return; + } + // Enrich call with caller identity so modules can resolve the calling agent const enrichedCall: ToolCall = { ...call, callerAgentName: agentName }; diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts new file mode 100644 index 000000000..6bbe320fd --- /dev/null +++ b/test/agent-allowed-tools.test.ts @@ -0,0 +1,285 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/framework.js'; +import type { AgentConfig, Module, ModuleContext, ToolCall, ToolResult } from '../src/types/index.js'; +import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; +import { ToolLifecycleEmitter, type ToolLifecycleParams } from '../src/mcpl/tool-lifecycle.js'; +import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; + +async function harness(config: Partial) { + const dir = mkdtempSync(join(tmpdir(), 'agent-allowed-tools-')); + const membrane = new MockMembrane(); + const effects: ToolCall[] = []; + let moduleContext: ModuleContext; + const module: Module = { + name: 'probe', + async start(ctx) { moduleContext = ctx; }, + async stop() {}, + getTools: () => ['allowed', 'denied'].map((name) => ({ + name, description: 'Inert dispatch counter', inputSchema: { type: 'object', properties: {} }, + })), + async handleToolCall(call) { + effects.push(call); + return { success: true, data: 'executed' }; + }, + async onProcess(event) { + return event.type === 'external-message' + ? { addMessages: [{ participant: 'user', content: [{ type: 'text', text: 'Run probe' }] }], requestInference: true } + : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test', systemPrompt: 'Test', ...config }], + modules: [module], syncIntervalMs: 0, codeExecution: { enabled: true }, + }); + const traces: Array> = []; + framework.onTrace((event) => traces.push(event as unknown as Record)); + const lifecycle: ToolLifecycleParams[] = []; + const emitter = new ToolLifecycleEmitter({ + observers: () => [{ + id: 'observer', grant: new CapabilityGrant(new Set(['toolLifecycle.observe']), []), + toolObserveFilter: null, sendToolLifecycle: (event) => lifecycle.push(event), + }], + configFor: () => undefined, describe: () => ({ class: [] }), + }); + // Use the real emitter with an in-memory observer, without an MCPL process. + const internal = framework as unknown as { + toolLifecycleEmitter: ToolLifecycleEmitter; + dispatchScriptToolCall(agent: string, tool: string, input: Record): Promise; + }; + internal.toolLifecycleEmitter = emitter; + return { + framework, membrane, effects, traces, lifecycle, emitter, internal, + moduleCall: (call: ToolCall) => moduleContext.callTool(call), + async turn(calls: ToolCall[]) { + membrane.pushResponse(createMockResponse(calls.map((call) => ({ + type: 'tool_use', id: call.id, name: call.name, input: call.input as Record, + })), 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done' }])); + framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await framework.runUntilIdle(); + return membrane.lastStream!.receivedToolResults[0] as Array<{ toolUseId: string; content: string; isError?: boolean }>; + }, + async close() { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, + }; +} + +const generic: ToolCall = { id: 'generic-denied', name: 'probe--denied', input: {} }; +const builtin: ToolCall = { + id: 'builtin-denied', name: 'agent_settings', + input: { action: 'update', same_round_think_text_policy: 'private' }, +}; + +for (const allowedTools of [['probe--allowed'], []]) { + for (const calls of [[generic], [builtin]]) { + test(`normal model dispatch denies ${calls[0].name} with ${JSON.stringify(allowedTools)}`, async () => { + const h = await harness({ allowedTools }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + const results = await h.turn(calls); + assert.equal(h.effects.length, 0, 'unlisted generic tool must have zero effects'); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before, 'unlisted builtin must have zero effects'); + assert.deepEqual(results.map((r) => r.toolUseId), calls.map((c) => c.id)); + for (const result of results) { + assert.equal(result.isError, true); + assert.match(result.content, /not permitted.*allowedTools/); + } + assert.deepEqual(h.lifecycle, [], 'refused calls must emit no RFC-007 lifecycle events'); + assert.equal(h.emitter.openCount, 0); + for (const call of calls) { + const events = h.traces.filter((e) => e.callId === call.id && String(e.type).startsWith('tool:')); + assert.equal(events.length, 1); + assert.equal(events[0].type, 'tool:failed'); + assert.equal(events[0].tool, call.name); + } + const compiled = await h.framework.getAgent('assistant')!.compileContext(); + const blocks = compiled.messages.flatMap((m) => m.content); + for (const call of calls) { + assert.ok(blocks.some((b) => b.type === 'tool_use' && b.id === call.id)); + assert.ok(blocks.some((b) => b.type === 'tool_result' && b.toolUseId === call.id && b.isError)); + } + } finally { await h.close(); } + }); + } +} + +for (const allowedTools of [undefined, 'all', ['probe--denied', 'agent_settings']] as Array) { + test(`allowed model calls retain effects and paired results: ${JSON.stringify(allowedTools)}`, async () => { + const h = await harness({ allowedTools }); + try { + const results = await h.turn([generic, builtin]); + assert.equal(h.effects.length, 1); + assert.equal(h.framework.getAgentRuntimeSettings('assistant').sameRoundThinkTextPolicy, 'private'); + assert.deepEqual(results.map((r) => r.toolUseId).sort(), [generic.id, builtin.id].sort()); + assert.ok(results.every((r) => !r.isError)); + assert.deepEqual(h.lifecycle.map((e) => e.phase).sort(), ['completed', 'completed', 'started', 'started']); + assert.equal(h.emitter.openCount, 0); + } finally { await h.close(); } + }); +} + +test('explicit prose helper remains advertised and executable with an empty list', async () => { + const h = await harness({ allowedTools: [], proseRouting: 'explicit' }); + try { + assert.ok(h.framework.listToolClasses('assistant').some((t) => t.tool === 'prose_help')); + const [result] = await h.turn([{ id: 'help', name: 'prose_help', input: {} }]); + assert.equal(result.toolUseId, 'help'); + assert.ok(!result.isError); + assert.match(result.content, />>/); + } finally { await h.close(); } +}); + +test('shared script dispatch enforces the resident list and resolves its real event waiter', async () => { + const h = await harness({ allowedTools: ['probe--allowed'] }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + for (const call of [generic, builtin, { id: 'allowed', name: 'probe--allowed', input: {} }]) { + const pending = h.internal.dispatchScriptToolCall('assistant', call.name, call.input as Record); + await h.framework.runUntilIdle(); + const result = await pending; + assert.equal(result.success, call.name === 'probe--allowed'); + if (!result.success) { + assert.equal(result.isError, true); + assert.match(result.error!, /not permitted.*allowedTools/); + } + } + assert.deepEqual(h.effects.map((c) => c.name), ['allowed']); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before); + } finally { await h.close(); } +}); + +test('model-issued Python cannot forge unlisted inner generic or builtin calls', async () => { + const h = await harness({ allowedTools: ['code_execution', 'probe--allowed'] }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + const [result] = await h.turn([{ + id: 'script', name: 'code_execution', input: { code: [ + 'make = probe__allowed.__globals__["_make_tool_fn"]', + 'for name, args in [("probe--denied", {}), ("agent_settings", {"action": "update", "same_round_think_text_policy": "private"})]:', + ' try:', + ' print(await make(name, "forged")(args))', + ' except Exception as error:', + ' print(str(error))', + 'print(await probe__allowed())', + ].join('\n') }, + }]); + assert.equal(result.toolUseId, 'script'); + assert.ok(!result.isError); + const execution = JSON.parse(result.content) as { stdout: string; stderr: string; return_code: number }; + assert.equal(execution.return_code, 0, execution.stderr); + assert.equal((execution.stdout.match(/not permitted.*allowedTools/g) ?? []).length, 2); + assert.deepEqual(h.effects.map((c) => c.name), ['allowed']); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before); + } finally { await h.close(); } +}); + +test('unregistered programmatic ephemeral script callers retain their full surface', async () => { + const h = await harness({ allowedTools: [] }); + try { + const pending = h.framework.executeToolCall({ + id: 'ephemeral-script', name: 'code_execution', callerAgentName: 'unregistered-ephemeral', + input: { code: 'print(await probe__denied())' }, + }); + // The Python subprocess enqueues its inner call after startup. + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 10); + try { + const result = await pending; + assert.equal(result.success, true); + assert.equal((result.data as { return_code: number }).return_code, 0); + assert.equal(h.effects.length, 1); + } finally { clearInterval(processing); } + } finally { await h.close(); } +}); + +for (const call of [generic, { id: 'public-script', name: 'code_execution', input: { code: 'print("forbidden")' } }]) { + test(`registered public dispatch refuses ${call.name} before effects`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const result = await h.framework.executeToolCall({ ...call, callerAgentName: 'assistant' }); + assert.equal(result.success, false); + assert.equal(result.isError, true); + assert.match(result.error!, /not permitted.*allowedTools/); + assert.equal(h.effects.length, 0); + assert.deepEqual(h.lifecycle, []); + assert.equal(h.traces.filter((e) => e.callId === call.id && e.type === 'tool:failed').length, 1); + // A refused code_execution must not even create an interpreter. + assert.equal((h.framework as unknown as { codeExecutionRunners: Map }).codeExecutionRunners.size, 0); + } finally { await h.close(); } + }); +} + +test('trusted module closure bypasses resident restrictions even with a registered caller identity', async () => { + const h = await harness({ allowedTools: [] }); + try { + const result = await h.moduleCall({ ...generic, callerAgentName: 'assistant' }); + assert.equal(result.success, true); + assert.deepEqual(h.effects.map((c) => c.name), ['denied']); + } finally { await h.close(); } +}); + +// Same channel capture contract as explicit-prose-routing.test.ts. +function captureRouting(framework: AgentFramework) { + const routed: string[] = []; + const registry: Record = { + handleChannelToolCall: async () => ({ success: true, data: 'ok' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, + resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => null, + isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { + get: (target, prop: string) => prop in target ? target[prop] : () => undefined, + }); + return routed; +} + +for (const fallback of [false, true]) { + for (const name of ['skip_reply', 'think', 'channel_publish']) { + for (const permitted of [false, true]) { + test(`${fallback ? 'fallback' : 'native'} prose disposition ${permitted ? 'honors permitted' : 'ignores denied'} ${name}`, async () => { + const h = await harness({ allowedTools: permitted ? [name] : [], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'disposition', name, input: { reason: 'test', content: 'test' } }; + if (fallback) { + const streamYielding = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'tool-calls') { + yield { ...event, context: { ...event.context, roundContent: undefined } }; + } else if (event.type === 'complete') { + // Older membrane completion contains the cumulative tool round. + yield { ...event, response: { ...event.response, content: [call, ...event.response.content] } }; + } else yield event; + } + }; + return stream; + }; + } + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + if (!permitted) { + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/); + assert.ok(routed.includes('Public trailing prose'), 'denied tools cannot silence trailing public prose'); + if (!fallback) assert.ok(routed.includes('Public round prose'), 'denied think cannot privatize same-round public prose'); + } else if (name === 'think') { + assert.ok(routed.includes('Public trailing prose')); + if (!fallback) assert.ok(!routed.includes('Public round prose'), 'permitted private think retains same-round policy'); + } else { + assert.deepEqual(routed, [], 'permitted silencing calls retain prose disposition'); + } + } finally { await h.close(); } + }); + } + } +} From 9707e2a9ddb815a12bcad4ee60634f9b78d772ab Mon Sep 17 00:00:00 2001 From: Aleksandr Markov Date: Wed, 14 Oct 2026 21:32:26 +0200 Subject: [PATCH 2/8] docs(changelog): classify utility migration as breaking Co-Authored-By: GPT-6 via OpenAI Codex --- changelog.d/agent-allowed-tools.breaking.md | 1 + changelog.d/agent-allowed-tools.fixed.md | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 changelog.d/agent-allowed-tools.breaking.md diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md new file mode 100644 index 000000000..5eac2bee0 --- /dev/null +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -0,0 +1 @@ +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as well as `utils`, or use `"all"`. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain their behavior. Adopt this change with the connectome-host release containing https://github.com/anima-research/connectome-host/pull/191; its minimum released version is pending that release. Release this compatibility change in a minor version so hosts using `^0.22.1` do not receive it as a patch without the companion. diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md index e0eaf92aa..01af466b3 100644 --- a/changelog.d/agent-allowed-tools.fixed.md +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -1,3 +1 @@ - Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state and cannot change public prose disposition. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. - -- Explicit `allowedTools` lists must name each permitted utility as well as `utils`, or use `"all"`. Expose the existing namespaced utility catalogue through `getAllUtilities()` for callers that construct such lists. Adopt this release together with the maximum-depth list correction in connectome-host #191. From 5fc0aa4830e1f3d83a09a53c8755586c52a97c2a Mon Sep 17 00:00:00 2001 From: Aleksandr Markov Date: Thu, 15 Oct 2026 15:29:59 +0200 Subject: [PATCH 3/8] fix(tools): make utility permission migration explicit Address R1-1, R1-2 and R1-3: hide empty utility surfaces, warn once per agent, distinguish denied utilities and unregistered identities, and document exact dispatch authority. Co-Authored-By: GPT-6.1 Sol via OpenAI Codex --- README.md | 19 +- changelog.d/agent-allowed-tools.breaking.md | 2 +- src/framework.ts | 59 +++++-- src/types/agent.ts | 10 +- test/agent-allowed-tools.test.ts | 183 +++++++++++++++++++- 5 files changed, 253 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6bed35eb6..ec7dbbe4c 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,30 @@ Chronicle's audit history. model: 'claude-opus-4-20250514', systemPrompt: '...', strategy: new AutobiographicalStrategy({ recentWindowTokens: 30000 }), - allowedTools: ['search', 'read', 'write'], // or 'all' + allowedTools: ['search--query', 'utils', 'files--archive'], // or 'all' triggerSources: ['discord'], // or 'all' maxTokens: 8192, maxStreamTokens: 150_000, // input token budget before stream restart } ``` +`allowedTools` is enforced at dispatch for registered agents. Omission or +`'all'` permits all tools; an explicit list matches exact names, including +`module--utility` names. A utility's own name permits a direct model, public +`executeToolCall`, or script call without `utils`. Adding `utils` exposes the +list/describe/run slot; `run` still requires the named utility grant. The slot +is hidden when the agent can use no registered utility. If utilities exist +but none are permitted, `list` returns a failed `ToolResult` with the registered +and permitted counts and migration advice. A list containing `utils` with no +permitted utility logs one warning per Agent instance after registration. +An empty registry still returns a successful empty list. + +Trusted module calls are exempt from agent allowlists. Genuinely unidentified +public callers, including unknown legacy caller names, remain unrestricted. +Framework-created ephemeral and conversation identities fail closed while +unregistered. Hosts using `createEphemeralAgent` must register and run the +returned agent through `runEphemeralToCompletion` for tool authority. + **Inference modes:** - **Streaming** (default in framework): `startStreamWithInjections()` returns a `YieldingStream` that emits tokens, tool calls, and completion events. The framework drives the stream, dispatches tools, and resumes automatically. - **Request/response**: `runInferenceWithInjections()` for simple complete-and-return usage with optional `AbortSignal` for cancellation. diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md index 5eac2bee0..ea773f355 100644 --- a/changelog.d/agent-allowed-tools.breaking.md +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -1 +1 @@ -- **Hosts:** explicit `allowedTools` lists must name each permitted utility as well as `utils`, or use `"all"`. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain their behavior. Adopt this change with the connectome-host release containing https://github.com/anima-research/connectome-host/pull/191; its minimum released version is pending that release. Release this compatibility change in a minor version so hosts using `^0.22.1` do not receive it as a patch without the companion. +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. That name alone permits direct model, public and script calls; `utils` additionally grants the list/describe/run slot, whose `run` still checks the utility name. In utilities mode, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of a successful empty array. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the connectome-host release containing https://github.com/anima-research/connectome-host/pull/191; its minimum released version is pending that release. Release this compatibility change in a minor version so hosts using `^0.22.1` do not receive it as a patch without the companion. diff --git a/src/framework.ts b/src/framework.ts index 3a2c5c18e..ef657464c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1739,6 +1739,8 @@ export class AgentFramework { private usedEphemeralAgentNames: Set = new Set(); /** One-shot generation tickets minted by createEphemeralAgent. */ private ephemeralCandidates: WeakMap = new WeakMap(); + /** Warn only once per Agent instance after utilities are registered. */ + private emptyUtilityPermissionWarnings: WeakSet = new WeakSet(); /** Physical ephemeral frames deliberately disposed by their run watchdog/caller. * The name is single-generation, so these frames still own their terminal * typing/outgoing close even after runEphemeralToCompletion deregisters them. */ @@ -2266,6 +2268,9 @@ export class AgentFramework { for (const module of config.modules) { await framework.addModule(module); } + for (const agent of framework.agents.values()) { + framework.warnEmptyUtilityPermissions(agent); + } for (const [agentName, presentation] of framework.toolPresentations) { const workspace = framework.getWorkspaceModule(); if (!workspace) throw new Error('Tool presentation requires workspace'); @@ -4103,6 +4108,15 @@ export class AgentFramework { }; } + private warnEmptyUtilityPermissions(agent: Agent): void { + if (agent.allowedTools === 'all' || !agent.allowedTools.includes('utils') + || this.emptyUtilityPermissionWarnings.has(agent)) return; + const utilities = this.moduleRegistry.getAllUtilities(); + if (utilities.length === 0 || utilities.some(tool => agent.canUseTool(tool.name))) return; + console.warn(`[utils] Agent '${agent.name}': ${utilities.length} registered utilities, 0 permitted; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`); + this.emptyUtilityPermissionWarnings.add(agent); + } + private getToolsForAgent( agentName: string, snapshot?: InferenceToolSnapshot, @@ -4121,7 +4135,11 @@ export class AgentFramework { : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])].map((tool) => { + const agent = this.agents.get(agentName); + if (agent) this.warnEmptyUtilityPermissions(agent); + const hasPermittedUtility = !agent || this.moduleRegistry.getAllUtilities().some(tool => agent.canUseTool(tool.name)); + return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])] + .filter(tool => tool.name !== 'utils' || hasPermittedUtility).map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy @@ -5676,8 +5694,10 @@ export class AgentFramework { /** * Create an ephemeral agent that is NOT registered in the main event loop. * - * Used by SubagentModule (and similar) to create short-lived agents - * that are driven externally (not by the framework's event loop). + * Used by SubagentModule (and similar) to prepare short-lived agents. + * Call runEphemeralToCompletion(agent, contextManager) to register and run + * the returned agent. Framework-created identities have tool authority only + * while registered; public tool calls before a run or after retirement fail. * The returned agent has its own ContextManager on a namespaced state * within the framework's shared Chronicle store — messages go to * `{namespace}/messages`, context log to `{namespace}/context`. @@ -13412,8 +13432,12 @@ export class AgentFramework { * call entered through — a public method shared with model callers can * never confer module trust, whatever the input claims. Module origin * exists only through the private closure handed to ModuleRegistry - * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that - * don't identify themselves fail safe to agent semantics too. + * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt + * from agent allowlists. Registered agents' allowedTools is enforced at + * dispatch, including namespaced utility names; utils only grants the + * list/describe/run slot. Genuinely unidentified public callers (including + * unknown legacy names) remain unrestricted. Framework-created identities + * are refused while unregistered, even if their configured list allows it. */ async executeToolCall(call: ToolCall): Promise { return this.executeToolCallFrom(call, { @@ -14850,7 +14874,9 @@ export class AgentFramework { private agentToolPermissionFailure(agentName: string, call: ToolCall, reportRefusal = true): ToolResult | undefined { if (this.canAgentUseTool(agentName, call.name)) return undefined; - const error = `Tool '${call.name}' is not permitted by this agent's allowedTools.`; + const error = !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName) + ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered.` + : `Tool '${call.name}' is not permitted by this agent's allowedTools.`; if (reportRefusal) { this.toolLifecycleEmitter?.refuse(agentName, call.id); this.emitTrace({ @@ -18959,25 +18985,36 @@ export class AgentFramework { const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown }; const allUtilities = this.moduleRegistry.getAllUtilities(); const utilities = allUtilities.filter(def => options.trustedModule || this.canAgentUseTool(agentName, def.name)); - const available = (): string => - utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)'; + const counts = `${allUtilities.length} registered utilities, ${utilities.length} permitted`; + const available = (): string => utilities.length + ? `Available: ${utilities.map((u) => u.name).join(', ')} (${counts})` + : allUtilities.length + ? `${counts}; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.` + : `${counts} (none registered)`; switch (input.action) { case 'list': + if (allUtilities.length > 0 && utilities.length === 0) { + return { success: false, isError: true, error: available() }; + } return { success: true, data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })), }; case 'describe': { - const def = utilities.find((u) => u.name === input.name); + const def = allUtilities.find((u) => u.name === input.name); if (!def) { - return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` }; + return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` }; + } + if (!options.trustedModule) { + const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false); + if (refusal) return refusal; } return { success: true, data: def }; } case 'run': { const def = allUtilities.find((u) => u.name === input.name); if (!def) { - return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` }; + return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` }; } if (!options.trustedModule) { // The allowed outer utils call owns its lifecycle and terminal trace; diff --git a/src/types/agent.ts b/src/types/agent.ts index dce7b9a81..e67c2101d 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -27,9 +27,15 @@ export interface AgentConfig { strategy?: ContextStrategy; /** - * Which tools this agent can use. + * Enforced at dispatch for registered agent callers; omitted means 'all'. * - 'all': all available tools - * - string[]: specific tool names (with module prefix) + * - string[]: exact tool names, including module--utility names for utilities. + * A named utility grant permits direct model, public and script calls. + * 'utils' additionally grants the list/describe/run slot; run still checks + * the utility name. The slot is hidden when no utility is permitted. + * Trusted module calls are exempt. Genuinely unidentified public callers, + * including unknown legacy names, remain unrestricted. Framework-created + * identities have tool authority only while registered. */ allowedTools?: 'all' | string[]; diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index be6f80042..a86e3859a 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -9,7 +9,7 @@ import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; import { ToolLifecycleEmitter, type ToolLifecycleParams } from '../src/mcpl/tool-lifecycle.js'; import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; -async function harness(config: Partial, probeHandler?: (call: ToolCall) => Promise) { +async function harness(config: Partial, probeHandler?: (call: ToolCall) => Promise, deferModule = false) { const dir = mkdtempSync(join(tmpdir(), 'agent-allowed-tools-')); const membrane = new MockMembrane(); const effects: ToolCall[] = []; @@ -37,7 +37,7 @@ async function harness(config: Partial, probeHandler?: (call: ToolC const framework = await AgentFramework.create({ storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'Test', ...config }], - modules: [module], syncIntervalMs: 0, codeExecution: { enabled: true }, + modules: deferModule ? [] : [module], syncIntervalMs: 0, codeExecution: { enabled: true }, conversations: { templateAgent: 'assistant', bind: { channel: 'mention' } }, }); const traces: Array> = []; @@ -53,11 +53,12 @@ async function harness(config: Partial, probeHandler?: (call: ToolC // Use the real emitter with an in-memory observer, without an MCPL process. const internal = framework as unknown as { toolLifecycleEmitter: ToolLifecycleEmitter; + getToolsForAgent(name: string): ReturnType; dispatchScriptToolCall(agent: string, tool: string, input: Record): Promise; }; internal.toolLifecycleEmitter = emitter; return { - framework, membrane, effects, traces, lifecycle, emitter, internal, + framework, membrane, effects, traces, lifecycle, emitter, internal, module, moduleCall: (call: ToolCall) => moduleContext.callTool(call), async turn(calls: ToolCall[]) { membrane.pushResponse(createMockResponse(calls.map((call) => ({ @@ -353,7 +354,7 @@ for (const route of ['model', 'public', 'script'] as const) { }); } -for (const allowedTools of [['utils', 'probe--allowed_util'], ['utils'], 'all'] as AgentConfig['allowedTools'][]) { +for (const allowedTools of [['utils', 'probe--allowed_util'], 'all'] as AgentConfig['allowedTools'][]) { test(`utility list and describe expose only executable names: ${JSON.stringify(allowedTools)}`, async () => { const h = await harness({ allowedTools }); const call = (input: unknown) => h.framework.executeToolCall({ id: 'utility', name: 'utils', input, callerAgentName: 'assistant' }); @@ -464,7 +465,7 @@ test('script in flight cannot gain legacy permissions when its ephemeral run dis assert.equal(h.framework.getAgent(created.agent.name), null); release(); const result = await script; assert.equal(result.success, true); - assert.match((result.data as { stdout: string }).stdout, /not permitted.*allowedTools/); + assert.match((result.data as { stdout: string }).stdout, /not a running agent.*only while registered/); assert.deepEqual(h.effects.map(call => call.input), [{phase: 'barrier'}]); assert.equal(h.emitter.openCount, 0); assert.equal((h.framework as unknown as { scriptToolWaiters: Map }).scriptToolWaiters.size, 0); @@ -503,3 +504,175 @@ test('public utility catalogue preserves names and the collapsed model surface', assert.equal(framework.getAllUtilities().length, 2); } finally { await h.close(); } }); + + +for (const allowedTools of [['utils'], ['utils', 'probe--allowed']]) { + test(`empty permitted utility surface reports migration once: ${JSON.stringify(allowedTools)}`, async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools }); + try { + assert.equal(warnings.length, 1, 'resident warns after initial modules register'); + assert.match(warnings[0], /assistant.*2 registered.*none permitted.*allowedTools.*module--utility/); + assert.ok(h.framework.getAllTools().some(tool => tool.name === 'utils'), 'global catalogue is unchanged'); + for (let i = 0; i < 2; i++) { + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + assert.ok(!(await h.framework.previewActivation('assistant')).tools?.some(tool => tool.name === 'utils')); + } + const call = (input: unknown) => h.framework.executeToolCall({ id: 'empty-utilities', name: 'utils', input, callerAgentName: 'assistant' }); + const listed = await call({ action: 'list' }); + assert.equal(listed.success, false); assert.equal(listed.isError, true); + assert.match(listed.error!, /2 registered.*0 permitted.*none permitted.*allowedTools.*module--utility/); + assert.equal(listed.data, undefined, 'empty permission diagnostic uses existing failure shape'); + const unknown = await call({ action: 'describe', name: 'missing' }); + assert.equal(unknown.isError, true); + assert.match(unknown.error!, /2 registered.*0 permitted.*none permitted.*allowedTools/); + assert.ok(!JSON.stringify(unknown).includes('probe--denied_util'), 'unknown diagnostics reveal no denied names'); + assert.ok(!JSON.stringify(unknown).includes('Inert utility counter')); + assert.equal(warnings.length, 1, 'repeat surfaces do not repeat the warning'); + assert.deepEqual(h.effects, []); + } finally { await h.close(); console.warn = warn; } + }); +} + +test('zero-module snapshots do not consume the later utility migration warning', async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools: ['utils'] }, undefined, true); + try { + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + assert.deepEqual(warnings, []); + const empty = await h.framework.executeToolCall({ id: 'no-utilities', name: 'utils', input: { action: 'list' }, callerAgentName: 'assistant' }); + assert.equal(empty.success, true); assert.deepEqual(empty.data, []); + await h.framework.addModule(h.module); + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + h.internal.getToolsForAgent('assistant'); + assert.equal(warnings.length, 1); assert.match(warnings[0], /assistant.*2 registered/); + } finally { await h.close(); console.warn = warn; } +}); + +for (const allowedTools of [['utils', 'probe--allowed_util'], ['probe--allowed_util'], 'all'] as AgentConfig['allowedTools'][]) { + test(`permitted utilities retain surface without migration warnings: ${JSON.stringify(allowedTools)}`, async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools }); + try { + const surface = (await h.framework.previewActivation('assistant')).tools ?? []; + assert.equal(surface.some(tool => tool.name === 'utils'), allowedTools === 'all' || allowedTools!.includes('utils')); + assert.deepEqual(warnings, []); + } finally { await h.close(); console.warn = warn; } + }); +} + +for (const route of ['model', 'public', 'script'] as const) { + test(`denied utility describe matches run refusal on ${route}`, async () => { + const h = await harness({ allowedTools: ['utils', 'probe--allowed_util'] }); + try { + const errors: string[] = []; + for (const action of ['describe', 'run']) { + const input = { action, name: 'probe--denied_util', args: {} }; + const id = `denied-${action}`; + if (route === 'model') { + const [result] = await h.turn([{ id, name: 'utils', input }]); + assert.equal(result.isError, true); errors.push(result.content); + const terminals = h.traces.filter(event => event.callId === id && ['tool:failed', 'tool:completed'].includes(event.type as string)); + assert.deepEqual(terminals.map(event => event.tool), ['utils']); + } else { + const pending = route === 'public' + ? h.framework.executeToolCall({ id, name: 'utils', input, callerAgentName: 'assistant' }) + : h.internal.dispatchScriptToolCall('assistant', 'utils', input); + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 5); + try { + const result = await pending; + assert.equal(result.isError, true); errors.push(result.error!); + } finally { clearInterval(processing); } + } + } + assert.equal(errors[0], errors[1]); + assert.match(errors[0], /not permitted.*allowedTools/); + assert.ok(!errors[0].includes('Inert utility counter')); + assert.equal(h.emitter.openCount, 0); assert.deepEqual(h.effects, []); + const unknown = await h.framework.executeToolCall({ id: 'unknown', name: 'utils', input: { action: 'describe', name: 'missing' }, callerAgentName: 'assistant' }); + assert.match(unknown.error!, /2 registered.*1 permitted/); + assert.ok(!unknown.error!.includes('probe--denied_util')); + } finally { await h.close(); } + }); + + test(`named utility grant alone executes on ${route}`, async () => { + const h = await harness({ allowedTools: ['probe--allowed_util'] }); + const call = { id: 'direct-utility', name: 'probe--allowed_util', input: {}, callerAgentName: 'assistant' }; + try { + if (route === 'model') { + const [result] = await h.turn([call]); assert.ok(!result.isError); + } else { + const pending = route === 'public' ? h.framework.executeToolCall(call) : h.internal.dispatchScriptToolCall('assistant', call.name, {}); + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 5); + try { assert.equal((await pending).success, true); } + finally { clearInterval(processing); } + } + assert.deepEqual(h.effects.map(effect => effect.name), ['allowed_util']); + } finally { await h.close(); } + }); +} + +test('never-run and retired ephemerals report missing registration before utility migration advice', async () => { + const h = await harness({ allowedTools: 'all' }); + try { + const created = await h.framework.createEphemeralAgent({ name: 'identity-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['utils', 'probe--allowed_util'] }); + const check = async () => { + const before = h.effects.length; + for (const [name, input] of [['probe--allowed_util', {}], ['utils', { action: 'list' }], ['utils', { action: 'describe', name: 'missing' }]] as const) { + const result = await h.framework.executeToolCall({ id: `unregistered-${name}`, name, input, callerAgentName: created.agent.name }); + assert.equal(result.success, false); assert.equal(result.isError, true); + assert.match(result.error!, /identity-worker.*not a running agent.*only while registered/); + assert.ok(!result.error!.includes('allowedTools'), 'identity failure must not give allowlist advice'); + } + assert.equal(h.effects.length, before); assert.deepEqual(h.lifecycle, []); assert.equal(h.emitter.openCount, 0); + }; + await check(); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete.' }]); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); + const run = h.framework.runEphemeralToCompletion(created.agent, created.contextManager); + await h.framework.runUntilIdle(); await run; + assert.equal(h.framework.getAgent(created.agent.name), null); + await check(); created.cleanup(); + } finally { await h.close(); } +}); + +test('ephemeral and conversation fork first surfaces warn once per Agent instance', async () => { + const h = await harness({ allowedTools: ['utils'] }); + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + try { + const internals = h.framework as unknown as { createConversationAgent(name: string, channel: string): Promise<{name: string}>; disposeConversationAgent(name: string): void }; + const fork = await internals.createConversationAgent('utility-fork-g1', 'world:probe'); + const created = await h.framework.createEphemeralAgent({ name: 'utility-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['utils'] }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete.' }]); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); + const run = h.framework.runEphemeralToCompletion(created.agent, created.contextManager); + for (const name of [fork.name, created.agent.name]) { + assert.ok(!h.internal.getToolsForAgent(name).some(tool => tool.name === 'utils')); + h.internal.getToolsForAgent(name); + assert.equal(warnings.filter(line => line.includes(name)).length, 1); + } + await h.framework.runUntilIdle(); await run; + assert.equal(warnings.length, 2); + internals.disposeConversationAgent(fork.name); created.cleanup(); + } finally { await h.close(); console.warn = warn; } +}); From 6702c7bc17512810e8a5f41a196305cfae290546 Mon Sep 17 00:00:00 2001 From: Aleksandr Markov Date: Fri, 16 Oct 2026 23:05:12 +0200 Subject: [PATCH 4/8] docs(tools): clarify utility migration and release boundary --- README.md | 4 ++-- changelog.d/agent-allowed-tools.breaking.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec7dbbe4c..414d796f5 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ Chronicle's audit history. list/describe/run slot; `run` still requires the named utility grant. The slot is hidden when the agent can use no registered utility. If utilities exist but none are permitted, `list` returns a failed `ToolResult` with the registered -and permitted counts and migration advice. A list containing `utils` with no -permitted utility logs one warning per Agent instance after registration. +and permitted counts and migration advice, and a list containing `utils` +logs one warning per Agent instance after registration. An empty registry still returns a successful empty list. Trusted module calls are exempt from agent allowlists. Genuinely unidentified diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md index ea773f355..120495d02 100644 --- a/changelog.d/agent-allowed-tools.breaking.md +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -1 +1 @@ -- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. That name alone permits direct model, public and script calls; `utils` additionally grants the list/describe/run slot, whose `run` still checks the utility name. In utilities mode, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of a successful empty array. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the connectome-host release containing https://github.com/anima-research/connectome-host/pull/191; its minimum released version is pending that release. Release this compatibility change in a minor version so hosts using `^0.22.1` do not receive it as a patch without the companion. +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. That name alone permits direct model, public and script calls; `utils` additionally grants the list/describe/run slot, whose `run` still checks the utility name. If you expose utilities through `utils`, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. From 44a27db7430109c34bd25ae8489a777c25cd1f67 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Sat, 17 Oct 2026 16:56:59 -0400 Subject: [PATCH 5/8] fix(tools): a refused skip_reply keeps its own round private, whatever refused it With main merged in, this branch and #198 gave a refused skip_reply's round opposite answers: #198 kept the round of a skip_reply refused for its wake private, while this branch sent out the round of one the agent's allowedTools doesn't name. A refusal withholds a call's effect, never the model's choice about its own words, so: - A skip_reply a round calls itself, refused because the agent's allowedTools doesn't name it or because this host can't set the wake it asks for, keeps that round's prose private and silences nothing after it: the silence before it, and its round's other permitted silencing calls, come back at the next round, as #198 does for a refused wake. In XML tool mode the turn-end delivery drops that round's text by its place. - A refused think keeps its round private under the private same-round policy: the round's tool names include every call the model made again, as on main. - A refused send posted nothing, so it silences nothing, its round's prose included; the round's silence check counts permitted calls, and a skip_reply refused for either reason, for its own round only. - The legacy scan keeps both filters: neither a call the list refuses nor a skip_reply whose wake is refused silences the turn's trailing prose. Texts: - The Breaking fragment, README and AgentConfig.allowedTools' doc say utilities aren't tools of their own: a model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name, so an agent that should use a utility lists both, and the name alone passes the dispatch check for any call by that name, whoever makes it. - The utils slot's description says "list" names the utilities the agent can use. - The Fixed fragment states the rule above for refused calls. Tests (test/agent-allowed-tools.test.ts): - the prose disposition cases of a refused call turn round for skip_reply (its round private in native and XML tool mode, the prose after it delivered) and think (its round private under the private policy), and stay for channel_publish (its round and what follows delivered); - the XML-mode simulation's turn-end response carries the round's prose, call and result, as membrane parses an XML turn, so a round is found by its place; - a skip_reply the list doesn't name, asking for a wake on a host with no EventGate, is refused by the list first, and its round stays private, in both modes; - a refused skip_reply beside a refused send keeps its round private, and neither silences what follows. --- README.md | 9 ++- changelog.d/agent-allowed-tools.breaking.md | 2 +- changelog.d/agent-allowed-tools.fixed.md | 2 +- src/framework.ts | 30 +++++--- src/types/agent.ts | 10 ++- test/agent-allowed-tools.test.ts | 79 +++++++++++++++++++-- 6 files changed, 109 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 119ac50ff..4e1139eef 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,12 @@ Chronicle's audit history. `allowedTools` is enforced at dispatch for registered agents. Omission or `'all'` permits all tools; an explicit list matches exact names, including -`module--utility` names. A utility's own name permits a direct model, public -`executeToolCall`, or script call without `utils`. Adding `utils` exposes the -list/describe/run slot; `run` still requires the named utility grant. The slot +`module--utility` names. Utilities aren't tools of their own: an agent's model +and its scripts are offered them only through `utils`, whose `run` checks the +utility's own name. An agent that should use a utility lists both `utils` and +`module--utility`. The name alone passes the dispatch check for any call by +that name, whether the host makes it with `executeToolCall` on the agent's +behalf or the model or a script makes it without being offered it. The slot is hidden when the agent can use no registered utility. If utilities exist but none are permitted, `list` returns a failed `ToolResult` with the registered and permitted counts and migration advice, and a list containing `utils` diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md index 120495d02..75411ddf6 100644 --- a/changelog.d/agent-allowed-tools.breaking.md +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -1 +1 @@ -- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. That name alone permits direct model, public and script calls; `utils` additionally grants the list/describe/run slot, whose `run` still checks the utility name. If you expose utilities through `utils`, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. Utilities aren't tools of their own: an agent's model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name. An agent that should use a utility lists both `utils` and `module--utility`. The name alone passes the dispatch check for any call by that name, whether the host makes it with `executeToolCall` on the agent's behalf or the model or a script makes it without being offered it. If you expose utilities through `utils`, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md index 01af466b3..0966b9f02 100644 --- a/changelog.d/agent-allowed-tools.fixed.md +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -1 +1 @@ -- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state and cannot change public prose disposition. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. +- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` keeps its round private under the private same-round policy. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. diff --git a/src/framework.ts b/src/framework.ts index 3c8f26f4a..2d947dec4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -361,7 +361,7 @@ const UTILS_TOOL: import('./types/index.js').ToolDefinition = { name: 'utils', description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' + - '"list" names them all with one-liners; "describe" returns one utility\'s full ' + + '"list" names the utilities you can use, with one-liners; "describe" returns one utility\'s full ' + 'input schema; "run" invokes it (args are validated against that schema — a ' + 'miss bounces back with the schema and the specific error).', inputSchema: { @@ -13044,11 +13044,17 @@ export class AgentFramework { (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages, ); - // A refused call doesn't silence: the silence before it, and its - // round's other silencing calls, come back at the next round. - if (event.calls.some((call) => this.refusesSelfWake(call))) { - silenceBeforeRefusal = turnSilenced - || event.calls.some((call) => isSilencingTool(call.name) && !this.refusesSelfWake(call)); + // A refused skip_reply, whether the agent's allowedTools doesn't + // name it or this host can't set the wake it asks for, keeps its + // own round private and silences nothing after it: the silence + // before it, and its round's other permitted silencing calls, + // come back at the next round. A refusal withholds the call's + // effect, not the model's choice to keep its words private. + const refusedSkipReply = (call: ToolCall): boolean => bareToolName(call.name) === 'skip_reply' + && (!this.canAgentUseTool(agent.name, call.name) || this.refusesSelfWake(call)); + if (event.calls.some(refusedSkipReply)) { + silenceBeforeRefusal = turnSilenced || event.calls.some((call) => + isSilencingTool(call.name) && this.canAgentUseTool(agent.name, call.name) && !refusedSkipReply(call)); refusedWakeRounds.add(toolRound); } @@ -13075,13 +13081,17 @@ export class AgentFramework { // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). if (this.channelRegistry) { - const roundToolNames = event.calls - .filter((c) => this.canAgentUseTool(agent.name, c.name)) - .map((c) => c.name); + // Every call the model made in the round, refused or not: a + // skip_reply or think refused still asks for the round's words + // to stay private. A refused send posted nothing, so it + // silences nothing; a refused skip_reply silences its own + // round, and the silence before it comes back at the next. + const roundToolNames = event.calls.map((c) => c.name); const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; - if (roundToolNames.some(isSilencingTool)) { + if (event.calls.some((c) => isSilencingTool(c.name) + && (this.canAgentUseTool(agent.name, c.name) || bareToolName(c.name) === 'skip_reply'))) { turnSilenced = true; } if (roundContent && roundContent.length > 0) { diff --git a/src/types/agent.ts b/src/types/agent.ts index 2e1d99205..bccf59983 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -30,9 +30,13 @@ export interface AgentConfig { * Enforced at dispatch for registered agent callers; omitted means 'all'. * - 'all': all available tools * - string[]: exact tool names, including module--utility names for utilities. - * A named utility grant permits direct model, public and script calls. - * 'utils' additionally grants the list/describe/run slot; run still checks - * the utility name. The slot is hidden when no utility is permitted. + * Utilities aren't tools of their own: an agent's model and its scripts are + * offered them only through 'utils', whose run checks the utility's own + * name. An agent that should use a utility lists both 'utils' and + * 'module--utility'. The name alone passes the dispatch check for any call + * by that name, whether the host makes it with executeToolCall on the + * agent's behalf or the model or a script makes it without being offered + * it. The slot is hidden when no utility is permitted. * Trusted module calls are exempt. Genuinely unidentified public callers, * including unknown legacy names, remain unrestricted. Framework-created * identities have tool authority only while registered. diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index a86e3859a..40c93596a 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -253,7 +253,7 @@ function captureRouting(framework: AgentFramework) { for (const fallback of [false, true]) { for (const name of ['skip_reply', 'think', 'channel_publish']) { for (const permitted of [false, true]) { - test(`${fallback ? 'fallback' : 'native'} prose disposition ${permitted ? 'honors permitted' : 'ignores denied'} ${name}`, async () => { + test(`${fallback ? 'fallback' : 'native'} prose disposition ${permitted ? 'honors permitted' : 'of a refused'} ${name}`, async () => { const h = await harness({ allowedTools: permitted ? [name] : [], sameRoundThinkTextPolicy: 'private' }); try { const routed = captureRouting(h.framework); @@ -268,8 +268,10 @@ for (const fallback of [false, true]) { if (event.type === 'tool-calls') { yield { ...event, context: { ...event.context, roundContent: undefined } }; } else if (event.type === 'complete') { - // Older membrane completion contains the cumulative tool round. - yield { ...event, response: { ...event.response, content: [call, ...event.response.content] } }; + // Older membrane completion contains the cumulative tool round: + // its prose, its call and its result, as XML mode parses them. + const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; + yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; } else yield event; } }; @@ -284,8 +286,17 @@ for (const fallback of [false, true]) { if (!permitted) { assert.equal(results[0].isError, true); assert.match(results[0].content, /not permitted.*allowedTools/); - assert.ok(routed.includes('Public trailing prose'), 'denied tools cannot silence trailing public prose'); - if (!fallback) assert.ok(routed.includes('Public round prose'), 'denied think cannot privatize same-round public prose'); + assert.ok(routed.includes('Public trailing prose'), 'a refused call silences nothing after its round'); + if (name === 'channel_publish') { + // A refused send posted nothing, so it has nothing to keep from repeating. + if (!fallback) assert.ok(routed.includes('Public round prose'), "a refused send's round goes out"); + } else if (name === 'skip_reply') { + // The model's choice to keep its words private holds without the call's effect. + assert.ok(!routed.includes('Public round prose'), + `a refused skip_reply keeps its own round private${fallback ? ', dropped by its place at turn end' : ''}`); + } else if (!fallback) { + assert.ok(!routed.includes('Public round prose'), 'a refused think keeps its round private under the private same-round policy'); + } } else if (name === 'think') { assert.ok(routed.includes('Public trailing prose')); if (!fallback) assert.ok(!routed.includes('Public round prose'), 'permitted private think retains same-round policy'); @@ -298,6 +309,64 @@ for (const fallback of [false, true]) { } } + +test("a refused skip_reply beside a refused send keeps its round private, and neither silences what follows", async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + h.membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Public round prose' }, + { type: 'tool_use', id: 'skip', name: 'skip_reply', input: { reason: 'quiet' } }, + { type: 'tool_use', id: 'send', name: 'channel_publish', input: { content: 'never posted' } }, + ], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.deepEqual(results.map((r) => r.isError), [true, true]); + assert.ok(!routed.includes('Public round prose'), 'the skip_reply keeps its round private'); + assert.ok(routed.includes('Public trailing prose'), 'the refused send silences nothing after the round'); + } finally { await h.close(); } +}); + +for (const fallback of [false, true]) { + test(`${fallback ? 'fallback' : 'native'}: a skip_reply the list doesn't name, asking for a wake on a host with no EventGate, is refused by the list first, and its round stays private`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + assert.equal((h.framework as unknown as { eventGate?: unknown }).eventGate ?? undefined, undefined, 'no EventGate'); + const call = { type: 'tool_use' as const, id: 'wake', name: 'skip_reply', input: { reason: 'later', wake_in_seconds: 60 } }; + if (fallback) { + const streamYielding = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'tool-calls') { + yield { ...event, context: { ...event.context, roundContent: undefined } }; + } else if (event.type === 'complete') { + const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; + yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; + } else yield event; + } + }; + return stream; + }; + } + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/, 'the permission refusal comes first'); + assert.ok(!routed.includes('Public round prose'), 'its round stays private'); + assert.ok(routed.includes('Public trailing prose'), 'and silences nothing after it'); + } finally { await h.close(); } + }); +} + for (const name of ['set_tool_visibility', 'set_tool_description']) { test(`presentation tools obey allowlist on model, script and registered public routes: ${name}`, async () => { const h = await harness({ allowedTools: ['probe--allowed'] }); From cf391b0e999237d2adb56e9d821a7dc42cfbff97 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Sat, 17 Oct 2026 17:52:56 -0400 Subject: [PATCH 6/8] fix(tools): say where the same-round think policy applies, and test a refused skip_reply under explicit routing and a refused send in XML mode - The Fixed fragment says a refused think is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. In XML tool mode a think's round goes out, permitted or refused, as on main. - The round's silence check reads the refused-skip_reply predicate rather than spelling its condition again: a permitted silencing call, or a skip_reply refused for either reason. - SILENCING_TOOLS' comment names both refusals of skip_reply, the list's and the wake's. Tests (test/agent-allowed-tools.test.ts): - XML tool mode: a refused think's round goes as a permitted think's does; - explicit prose routing: a refused skip_reply's round stays in context, and the trailing unprefixed prose bounces, as unprefixed prose does; - a refused send's round goes out in XML tool mode too, beside the refused skip_reply's round dropped by its place; - the XML-mode simulation is one helper the cases share. --- changelog.d/agent-allowed-tools.fixed.md | 2 +- src/framework.ts | 7 +- test/agent-allowed-tools.test.ts | 126 ++++++++++++++++------- 3 files changed, 92 insertions(+), 43 deletions(-) diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md index 0966b9f02..ba31ea536 100644 --- a/changelog.d/agent-allowed-tools.fixed.md +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -1 +1 @@ -- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` keeps its round private under the private same-round policy. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. +- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. diff --git a/src/framework.ts b/src/framework.ts index 2d947dec4..a73ed1fbd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -165,8 +165,9 @@ function sniffImageMediaType(data: Buffer): string | undefined { * another external message is injected. The suppression prevents a * `send_message` followed by "sent it" from double-posting, but a new message * starts a new conversational round and must be answerable with plain prose. - * `skip_reply` ends the turn at the tool-result boundary, unless it's refused - * (wake_in_seconds without an EventGate): then the turn goes on, and only its + * `skip_reply` ends the turn at the tool-result boundary, unless it's refused, + * because the agent's allowedTools doesn't name it or because it asks for + * wake_in_seconds without an EventGate: then the turn goes on, and only its * own round stays silent. */ const SILENCING_TOOLS = new Set([ @@ -13091,7 +13092,7 @@ export class AgentFramework { roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; if (event.calls.some((c) => isSilencingTool(c.name) - && (this.canAgentUseTool(agent.name, c.name) || bareToolName(c.name) === 'skip_reply'))) { + && (this.canAgentUseTool(agent.name, c.name) || refusedSkipReply(c)))) { turnSilenced = true; } if (roundContent && roundContent.length > 0) { diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index 40c93596a..4647bd84e 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -250,6 +250,31 @@ function captureRouting(framework: AgentFramework) { return routed; } +/** + * XML tool mode, as an older membrane streams it: no round carries its + * verbatim roundContent, so none is routed live, and the turn-end completion + * holds the cumulative tool round, its prose, its call and its result, as + * XML mode parses them, before the final text. + */ +function asXmlTurn(membrane: MockMembrane, call: { type: 'tool_use'; id: string; name: string; input: Record }): void { + const streamYielding = membrane.streamYielding.bind(membrane); + membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'tool-calls') { + yield { ...event, context: { ...event.context, roundContent: undefined } }; + } else if (event.type === 'complete') { + const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; + yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; + } else yield event; + } + }; + return stream; + }; +} + for (const fallback of [false, true]) { for (const name of ['skip_reply', 'think', 'channel_publish']) { for (const permitted of [false, true]) { @@ -258,26 +283,7 @@ for (const fallback of [false, true]) { try { const routed = captureRouting(h.framework); const call = { type: 'tool_use' as const, id: 'disposition', name, input: { reason: 'test', content: 'test' } }; - if (fallback) { - const streamYielding = h.membrane.streamYielding.bind(h.membrane); - h.membrane.streamYielding = (request, options) => { - const stream = streamYielding(request, options); - const iterate = stream[Symbol.asyncIterator].bind(stream); - stream[Symbol.asyncIterator] = async function* () { - for await (const event of { [Symbol.asyncIterator]: iterate }) { - if (event.type === 'tool-calls') { - yield { ...event, context: { ...event.context, roundContent: undefined } }; - } else if (event.type === 'complete') { - // Older membrane completion contains the cumulative tool round: - // its prose, its call and its result, as XML mode parses them. - const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; - yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; - } else yield event; - } - }; - return stream; - }; - } + if (fallback) asXmlTurn(h.membrane, call); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); @@ -289,12 +295,14 @@ for (const fallback of [false, true]) { assert.ok(routed.includes('Public trailing prose'), 'a refused call silences nothing after its round'); if (name === 'channel_publish') { // A refused send posted nothing, so it has nothing to keep from repeating. - if (!fallback) assert.ok(routed.includes('Public round prose'), "a refused send's round goes out"); + assert.ok(routed.includes('Public round prose'), "a refused send's round goes out"); } else if (name === 'skip_reply') { // The model's choice to keep its words private holds without the call's effect. assert.ok(!routed.includes('Public round prose'), `a refused skip_reply keeps its own round private${fallback ? ', dropped by its place at turn end' : ''}`); } else if (!fallback) { + // Where rounds are routed live, the private same-round policy + // reads a refused think as it reads a permitted one. assert.ok(!routed.includes('Public round prose'), 'a refused think keeps its round private under the private same-round policy'); } } else if (name === 'think') { @@ -310,6 +318,63 @@ for (const fallback of [false, true]) { } +test("XML tool mode: a refused think's round goes as a permitted think's does", async () => { + const dispositions: string[][] = []; + for (const permitted of [true, false]) { + const h = await harness({ allowedTools: permitted ? ['think'] : [], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'think', name: 'think', input: { thought: 'test' } }; + asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.equal(results[0].isError === true, !permitted); + dispositions.push(routed); + } finally { await h.close(); } + } + assert.deepEqual(dispositions[1], dispositions[0], 'the refusal changes nothing about which prose goes out'); +}); + +test("explicit prose routing: a refused skip_reply's round stays private, and what follows goes on as unprefixed prose does", async () => { + const h = await harness({ allowedTools: [], proseRouting: 'explicit' }); + try { + const routed = captureRouting(h.framework); + const bounced: number[] = []; + h.framework.onTrace((e) => { + const ev = e as { type: string; source?: string }; + if (ev.type === 'message:added' && ev.source === 'prose-bounce') bounced.push(1); + }); + h.membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Private round prose' }, + { type: 'tool_use', id: 'skip', name: 'skip_reply', input: { reason: 'quiet' } }, + ], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Unprefixed trailing prose' }])); + // A bounce wakes the agent again, and a stream takes the responses queued + // when it starts: answer each later turn privately as it starts. + let turns = 0; + h.framework.onTrace((e) => { + if ((e as { type: string }).type !== 'inference:started') return; + if (++turns >= 2) h.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply\nnoted' }])); + }); + const firstTurn: Array> = []; + const streamYielding = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + if (firstTurn.length === 0) firstTurn.push(h.membrane.lastStream!); + return stream; + }; + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = firstTurn[0]!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.equal(results[0].isError, true); + assert.deepEqual(routed, [], 'nothing is delivered'); + assert.equal(bounced.length, 1, "only the trailing round's unprefixed prose bounces; the refused skip_reply's round stays in context"); + } finally { await h.close(); } +}); + test("a refused skip_reply beside a refused send keeps its round private, and neither silences what follows", async () => { const h = await harness({ allowedTools: [] }); try { @@ -336,24 +401,7 @@ for (const fallback of [false, true]) { const routed = captureRouting(h.framework); assert.equal((h.framework as unknown as { eventGate?: unknown }).eventGate ?? undefined, undefined, 'no EventGate'); const call = { type: 'tool_use' as const, id: 'wake', name: 'skip_reply', input: { reason: 'later', wake_in_seconds: 60 } }; - if (fallback) { - const streamYielding = h.membrane.streamYielding.bind(h.membrane); - h.membrane.streamYielding = (request, options) => { - const stream = streamYielding(request, options); - const iterate = stream[Symbol.asyncIterator].bind(stream); - stream[Symbol.asyncIterator] = async function* () { - for await (const event of { [Symbol.asyncIterator]: iterate }) { - if (event.type === 'tool-calls') { - yield { ...event, context: { ...event.context, roundContent: undefined } }; - } else if (event.type === 'complete') { - const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; - yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; - } else yield event; - } - }; - return stream; - }; - } + if (fallback) asXmlTurn(h.membrane, call); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); From ebd1277874b0ec9d61dda7e933cb488c96efec2e Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Sat, 17 Oct 2026 22:41:37 -0400 Subject: [PATCH 7/8] fix(tools): name skip_reply in model-facing advice only where the agent may call it, and count one registered utility as one - R4-1: think's description, built per agent, and journal's, now set per agent too, tell the model to call skip_reply only where the agent's allowedTools permits it. A refused skip_reply leaves the turn going on, so the advice led a model that wanted to stay quiet to a refusal, and its next words out. think's result names no tool to end the turn with, since its handler can't see the caller's list; think's description names skip_reply where the agent may call it. - R4-2: a refused skip_reply keeps its round private by its bare name, so another module's module--skip_reply does too, in live, XML and explicit routing, as a permitted one is read by its bare name. The Fixed fragment says so, and tests pin a refused probe--skip_reply in each mode. - R4-3: the Breaking fragment drops "If you expose utilities through utils, add each required namespaced utility to the list", which the sentence before it replaced. - R2-2: the utils counts and the empty-permission warning say "1 registered utility". Tests: test/agent-allowed-tools.test.ts. test/same-round-think-policy.test.ts's mock registry carries think's new result note. Co-Authored-By: Claude Opus 5.5 --- changelog.d/agent-allowed-tools.breaking.md | 2 +- changelog.d/agent-allowed-tools.fixed.md | 2 +- src/framework.ts | 22 ++++-- src/mcpl/channel-registry.ts | 27 +++++--- test/agent-allowed-tools.test.ts | 74 ++++++++++++++++++++- test/same-round-think-policy.test.ts | 2 +- 6 files changed, 111 insertions(+), 18 deletions(-) diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md index 75411ddf6..ae9462e68 100644 --- a/changelog.d/agent-allowed-tools.breaking.md +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -1 +1 @@ -- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. Utilities aren't tools of their own: an agent's model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name. An agent that should use a utility lists both `utils` and `module--utility`. The name alone passes the dispatch check for any call by that name, whether the host makes it with `executeToolCall` on the agent's behalf or the model or a script makes it without being offered it. If you expose utilities through `utils`, add each required namespaced utility to the list; `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. Utilities aren't tools of their own: an agent's model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name. An agent that should use a utility lists both `utils` and `module--utility`. The name alone passes the dispatch check for any call by that name, whether the host makes it with `executeToolCall` on the agent's behalf or the model or a script makes it without being offered it. `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md index ba31ea536..64ef617d1 100644 --- a/changelog.d/agent-allowed-tools.fixed.md +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -1 +1 @@ -- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted. +- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply`, by its bare name, that a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted, and `think`'s and `journal`'s descriptions tell an agent to call `skip_reply` only when it may. `think`'s result no longer points at `skip_reply`. diff --git a/src/framework.ts b/src/framework.ts index a73ed1fbd..f0f35d9f9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -94,7 +94,7 @@ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.j import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js'; +import { ChannelRegistry, journalToolDescription, type ChannelToolOrigin } from './mcpl/channel-registry.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls, type ProviderWait } from './provider-waits.js'; @@ -355,6 +355,9 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { /** Strip the `server--` MCPL prefix from a tool name. */ const bareToolName = (n: string): string => n.split('--').pop()!; +/** "1 registered utility", "2 registered utilities". */ +const registeredUtilities = (n: number): string => `${n} registered ${n === 1 ? 'utility' : 'utilities'}`; + /** The single slot behind which every rarely-used capability lives. Only * advertised when at least one module contributes utilities — with none * registered the surface is byte-identical to before this existed. */ @@ -4509,8 +4512,10 @@ export class AgentFramework { return guards; } + /** `think` for one agent: its same-round policy, and `skip_reply` named only where the agent may call it. */ private buildThinkTool( policy: SameRoundThinkTextPolicy, + canSkipReply: boolean, ): import('./types/index.js').ToolDefinition { return { name: 'think', @@ -4521,7 +4526,8 @@ export class AgentFramework { ? 'Your current same_round_think_text_policy is private, so ordinary text emitted in the SAME native assistant round as think() is withheld from channel routing. Later rounds without think() route normally.' : 'Your current same_round_think_text_policy is public, so ordinary text emitted in the SAME native assistant round as think() may still be routed publicly as your speech. Later rounds without think() route normally.' ) + - ' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy. To deliberately not reply this turn, call skip_reply instead.', + ' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy.' + + (canSkipReply ? ' To deliberately not reply this turn, call skip_reply instead.' : ''), inputSchema: { type: 'object' as const, properties: { @@ -4579,7 +4585,7 @@ export class AgentFramework { || this.emptyUtilityPermissionWarnings.has(agent)) return; const utilities = this.moduleRegistry.getAllUtilities(); if (utilities.length === 0 || utilities.some(tool => agent.canUseTool(tool.name))) return; - console.warn(`[utils] Agent '${agent.name}': ${utilities.length} registered utilities, 0 permitted; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`); + console.warn(`[utils] Agent '${agent.name}': ${registeredUtilities(utilities.length)}, 0 permitted; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`); this.emptyUtilityPermissionWarnings.add(agent); } @@ -4587,6 +4593,10 @@ export class AgentFramework { agentName: string, snapshot?: InferenceToolSnapshot, ): import('./types/index.js').ToolDefinition[] { + const agent = this.agents.get(agentName); + // Model-facing advice names skip_reply only where this agent may call it: + // a refused skip_reply leaves the turn going on. + const canSkipReply = !agent || agent.canUseTool('skip_reply'); // The subconscious gets its own small surface (plus think/skip_reply/ // end_turn), not the residents' tool board: no channel lifecycle, no // gate rules, no workspace — it observes, judges, and reports. @@ -4597,11 +4607,11 @@ export class AgentFramework { ? this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, + canSkipReply, ) : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - const agent = this.agents.get(agentName); if (agent) this.warnEmptyUtilityPermissions(agent); const hasPermittedUtility = !agent || this.moduleRegistry.getAllUtilities().some(tool => agent.canUseTool(tool.name)); return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])] @@ -4610,8 +4620,10 @@ export class AgentFramework { return this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, + canSkipReply, ); } + if (tool.name === 'journal' && !canSkipReply) return { ...tool, description: journalToolDescription(false) }; return tool; }); } @@ -20118,7 +20130,7 @@ export class AgentFramework { const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown }; const allUtilities = this.moduleRegistry.getAllUtilities(); const utilities = allUtilities.filter(def => options.trustedModule || this.canAgentUseTool(agentName, def.name)); - const counts = `${allUtilities.length} registered utilities, ${utilities.length} permitted`; + const counts = `${registeredUtilities(allUtilities.length)}, ${utilities.length} permitted`; const available = (): string => utilities.length ? `Available: ${utilities.map((u) => u.name).join(', ')} (${counts})` : allUtilities.length diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index cde663e40..4e7016b54 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -327,6 +327,20 @@ function isFilledString(value: unknown): value is string { // Channel Tool Definitions // ============================================================================ +/** + * `journal`'s description. It tells the model to call `skip_reply` to end the + * turn only where the agent may call it: the framework builds each agent's + * surface with that agent's permission, and a refused `skip_reply` leaves the + * turn going on. + */ +export function journalToolDescription(canSkipReply: boolean): string { + return 'Write an entry in your private journal. The entry stays in your own context and ' + + 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' + + 'than a line that you want to keep for yourself — reflections, what you decided and ' + + 'why, notes for later. It does not end your turn and does not affect where ordinary ' + + 'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.'); +} + const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'channel_list', @@ -422,12 +436,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'journal', - description: - 'Write an entry in your private journal. The entry stays in your own context and ' + - 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' + - 'than a line that you want to keep for yourself — reflections, what you decided and ' + - 'why, notes for later. It does not end your turn and does not affect where ordinary ' + - 'text is routed; to end the turn without replying, call skip_reply afterwards.', + description: journalToolDescription(true), inputSchema: { type: 'object' as const, properties: { @@ -2949,10 +2958,12 @@ export class ChannelRegistry { // tool_use block; don't duplicate it in the result. data: { noted: true, + // No tool to end the turn with: this handler can't see whether the + // caller may call skip_reply, and think's own description says so + // where it may. note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' + - 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' + - 'call skip_reply to end the turn without replying.', + 'your current same_round_think_text_policy; use agent_settings get to inspect it.', }, }; } diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index 4647bd84e..41c4ab50b 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { AgentFramework } from '../src/framework.js'; import type { AgentConfig, Module, ModuleContext, ToolCall, ToolResult } from '../src/types/index.js'; import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; import { ToolLifecycleEmitter, type ToolLifecycleParams } from '../src/mcpl/tool-lifecycle.js'; import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; @@ -317,6 +318,26 @@ for (const fallback of [false, true]) { } } +for (const fallback of [false, true]) { + test(`${fallback ? 'fallback' : 'native'}: a refused skip_reply another module names (probe--skip_reply) keeps its round private by its bare name, and silences nothing after it`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'namespaced', name: 'probe--skip_reply', input: { reason: 'test' } }; + if (fallback) asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/); + assert.ok(!routed.includes('Public round prose'), 'the round that calls it stays private, as for skip_reply'); + assert.ok(routed.includes('Public trailing prose'), 'and what follows goes out'); + } finally { await h.close(); } + }); +} + test("XML tool mode: a refused think's round goes as a permitted think's does", async () => { const dispositions: string[][] = []; @@ -338,7 +359,7 @@ test("XML tool mode: a refused think's round goes as a permitted think's does", assert.deepEqual(dispositions[1], dispositions[0], 'the refusal changes nothing about which prose goes out'); }); -test("explicit prose routing: a refused skip_reply's round stays private, and what follows goes on as unprefixed prose does", async () => { +for (const skipName of ['skip_reply', 'probe--skip_reply']) test(`explicit prose routing: a refused ${skipName}'s round stays private, and what follows goes on as unprefixed prose does`, async () => { const h = await harness({ allowedTools: [], proseRouting: 'explicit' }); try { const routed = captureRouting(h.framework); @@ -349,7 +370,7 @@ test("explicit prose routing: a refused skip_reply's round stays private, and wh }); h.membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Private round prose' }, - { type: 'tool_use', id: 'skip', name: 'skip_reply', input: { reason: 'quiet' } }, + { type: 'tool_use', id: 'skip', name: skipName, input: { reason: 'quiet' } }, ], 'tool_use')); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Unprefixed trailing prose' }])); // A bounce wakes the agent again, and a stream takes the responses queued @@ -540,6 +561,29 @@ for (const permitted of [false, true]) { }); } +for (const permitted of [false, true]) { + test(`think's and journal's descriptions point at skip_reply only where the list permits it: ${permitted}`, async () => { + const h = await harness({ allowedTools: permitted ? ['think', 'journal', 'skip_reply'] : ['think', 'journal'] }); + const slot = h.framework as unknown as { channelRegistry: unknown }; + const original = slot.channelRegistry; + try { + // A host with channel servers has the registry, whose think and journal these are. + const registry = new ChannelRegistry({} as never, {} as never, () => {}, () => {}, {}); + slot.channelRegistry = registry; + const surface = h.internal.getToolsForAgent('assistant'); + for (const name of ['think', 'journal']) { + const tool = surface.find((t) => t.name === name); + assert.ok(tool, `${name} is on the surface`); + assert.equal(tool.description.includes('skip_reply'), permitted, `${name}'s description names skip_reply only where the agent may call it`); + } + // The result's handler can't see the caller's list, so it names no tool to end the turn with. + const result = await registry.handleChannelToolCall('think', { content: 'working it out' }); + assert.equal(result.success, true); + assert.ok(!JSON.stringify(result.data).includes('skip_reply'), `think's result names no skip_reply: ${JSON.stringify(result.data)}`); + } finally { slot.channelRegistry = original; await h.close(); } + }); +} + test('script in flight cannot gain legacy permissions when its ephemeral run disposes', async () => { let enter!: () => void, release!: () => void; const entered = new Promise(resolve => { enter = resolve; }); @@ -676,6 +720,32 @@ test('zero-module snapshots do not consume the later utility migration warning', } finally { await h.close(); console.warn = warn; } }); +test('one registered utility is counted as one utility', async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools: ['utils'] }, undefined, true); + try { + await h.framework.addModule({ + name: 'single', + async start() {}, async stop() {}, + getTools: () => [], + getUtilities: () => [{ name: 'only', description: 'Inert utility', inputSchema: { type: 'object', properties: {} } }], + async handleToolCall() { return { success: true, data: 'executed' }; }, + async onProcess() { return {}; }, + }); + h.internal.getToolsForAgent('assistant'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /: 1 registered utility, 0 permitted;/); + const listed = await h.framework.executeToolCall({ id: 'one-utility', name: 'utils', input: { action: 'list' }, callerAgentName: 'assistant' }); + assert.equal(listed.isError, true); + assert.match(listed.error!, /\b1 registered utility, 0 permitted;/); + } finally { await h.close(); console.warn = warn; } +}); + for (const allowedTools of [['utils', 'probe--allowed_util'], ['probe--allowed_util'], 'all'] as AgentConfig['allowedTools'][]) { test(`permitted utilities retain surface without migration warnings: ${JSON.stringify(allowedTools)}`, async () => { const warnings: string[] = []; diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index b3b3f252e..3268a397d 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -109,7 +109,7 @@ function makeChannelRegistry(framework: AgentFramework) { data: { noted: true, note: - 'Thought recorded (private — not sent anywhere). Same-round text routing depends on your current same_round_think_text_policy; use agent_settings get to inspect it, or call skip_reply to end the turn without replying.', + 'Thought recorded (private — not sent anywhere). Same-round text routing depends on your current same_round_think_text_policy; use agent_settings get to inspect it.', }, }; } From d2407bc438ddf1887299986024e24335b557fd84 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Sat, 17 Oct 2026 23:19:47 -0400 Subject: [PATCH 8/8] fix(tools): take the skip_reply tool out of prose_help's reference, decide skip_reply's advice as dispatch does, and test that a permitted think's round goes out in XML tool mode - R4-1's rule, carried to prose_help's reference: it says `>>skip_reply` keeps text in context only, without comparing it with the skip_reply tool, which an agent's allowedTools may not name. The envelope itself stays: it is the model's own choice about its words, which no list refuses. - getToolsForAgent decides think's and journal's skip_reply advice with canAgentUseTool, the rule dispatch uses, rather than spelling that rule again. Every caller passes a registered agent, so nothing changes for any of them. - The Fixed fragment puts another module's namespaced skip_reply under the allowedTools refusal alone, since the wake refusal applies only to the registry's own skip_reply. It also says prose_help's reference no longer points at the skip_reply tool. - Comments: the registry's static think description reaches no agent's surface, and the think handler's doc says an agent that may call skip_reply uses it to not reply. Tests: test/agent-allowed-tools.test.ts. In XML tool mode, a permitted think's round goes out. prose_help's reference names the envelope, not the tool. The XML think test passes think's content argument. Co-Authored-By: Claude Opus 5.5 --- changelog.d/agent-allowed-tools.fixed.md | 2 +- src/framework.ts | 8 ++++---- src/mcpl/channel-registry.ts | 6 +++++- test/agent-allowed-tools.test.ts | 9 ++++++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md index 64ef617d1..c6c0114eb 100644 --- a/changelog.d/agent-allowed-tools.fixed.md +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -1 +1 @@ -- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply`, by its bare name, that a round calls itself (not one a `code_execution` script makes), refused because the agent's allowedTools doesn't name it or because its wake can't be set, keeps that round's prose private and silences nothing after it, as a refused wake already did; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted, and `think`'s and `journal`'s descriptions tell an agent to call `skip_reply` only when it may. `think`'s result no longer points at `skip_reply`. +- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` that a round calls itself (not one a `code_execution` script makes) keeps that round's prose private and silences nothing after it, as a refused wake already did, when the agent's allowedTools doesn't name it, another module's namespaced `skip_reply` included, or when it's the registry's own `skip_reply` asking for a wake this host can't set; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted, and `think`'s and `journal`'s descriptions tell an agent to call `skip_reply` only when it may. `think`'s result and `prose_help`'s reference no longer point at the `skip_reply` tool. diff --git a/src/framework.ts b/src/framework.ts index f0f35d9f9..b17d65d11 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -342,7 +342,7 @@ const PROSE_ROUTING_HELP = ' The first destination in a turn applies to the rest of that turn.\n' + ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + - ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + + ' >>skip_reply — text stays in your context only.\n' + 'Text without a destination is not delivered: it is retained, and a notice will\n' + 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' + 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.'; @@ -4594,9 +4594,9 @@ export class AgentFramework { snapshot?: InferenceToolSnapshot, ): import('./types/index.js').ToolDefinition[] { const agent = this.agents.get(agentName); - // Model-facing advice names skip_reply only where this agent may call it: - // a refused skip_reply leaves the turn going on. - const canSkipReply = !agent || agent.canUseTool('skip_reply'); + // Model-facing advice names skip_reply only where dispatch would let this + // agent call it: a refused skip_reply leaves the turn going on. + const canSkipReply = this.canAgentUseTool(agentName, 'skip_reply'); // The subconscious gets its own small surface (plus think/skip_reply/ // end_turn), not the residents' tool board: no channel lifecycle, no // gate rules, no workspace — it observes, judges, and reports. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4e7016b54..6d0fbdce9 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -417,6 +417,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'think', + // No agent's surface shows this description: getToolsForAgent gives every + // agent its own think (buildThinkTool), which names skip_reply only where + // that agent may call it. description: 'Reason privately. The content stays in your own context and is NOT sent to any ' + 'channel or surface. Same-round routing of ordinary text beside think() depends on ' + @@ -2949,7 +2952,8 @@ export class ChannelRegistry { * Handle the synthesized `think` tool — a private reasoning scratchpad. It * sends nothing, and (unlike before) does NOT silence the turn: trailing * plain text is still routed as the reply. The thought stays in the agent's - * own context/chronicle. To deliberately not reply, the agent uses skip_reply. + * own context/chronicle. To deliberately not reply, an agent that may call + * skip_reply uses it. */ private handleToolThink(input: { content?: string }): ToolResult { return { diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index 41c4ab50b..2ec127c6e 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -137,6 +137,12 @@ for (const permitted of [false, true]) { assert.equal(result.toolUseId, 'help'); assert.equal(Boolean(result.isError), !permitted); assert.match(result.content, permitted ? />>/ : /not permitted.*allowedTools/); + if (permitted) { + // The reference names the `>>skip_reply` envelope, which no list refuses, and not the + // skip_reply tool, which this agent's list doesn't name. + assert.match(result.content, />>skip_reply/); + assert.doesNotMatch(result.content, /skip_reply tool/); + } } finally { await h.close(); } }); } @@ -345,7 +351,7 @@ test("XML tool mode: a refused think's round goes as a permitted think's does", const h = await harness({ allowedTools: permitted ? ['think'] : [], sameRoundThinkTextPolicy: 'private' }); try { const routed = captureRouting(h.framework); - const call = { type: 'tool_use' as const, id: 'think', name: 'think', input: { thought: 'test' } }; + const call = { type: 'tool_use' as const, id: 'think', name: 'think', input: { content: 'test' } }; asXmlTurn(h.membrane, call); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); @@ -356,6 +362,7 @@ test("XML tool mode: a refused think's round goes as a permitted think's does", dispositions.push(routed); } finally { await h.close(); } } + assert.ok(dispositions[0].includes('Public round prose'), "a permitted think's round goes out in XML tool mode, where no round is routed live"); assert.deepEqual(dispositions[1], dispositions[0], 'the refusal changes nothing about which prose goes out'); });