Skip to content

Commit dce9ce8

Browse files
authored
Resolve threads and set channel descriptions as the calling seat (#269)
A spawned Claude Code seat could not call `set_channel_description`, `resolve_thread` or `unresolve_thread`. Each failed with `UnboundEphemeralSession`. All three are writes the realm attributes to their actor. Resolving a topic posts a notice naming who did it, and a description change writes an audit row. So they already reached the bound credential, like `post`. But they took no host-supplied session id and sat outside the plugin's PreToolUse matcher, so no seat identity ever reached them. This change gives them the same supply as the other identity tools. They accept the injected `session_id` and `cwd`, and the hook matcher now stamps them. Each call binds the seat of the conversation making it, never one an earlier call left bound. The hooks-manifest test kept these three as a named exception to its rule that every tool reaching the bound credential receives a session id and is stamped. The exception list is gone, so the rule now holds unqualified. The end-to-end deployed-wiring test now checks that each verb binds the calling conversation's seat, where it used to pin the refusal.
1 parent f3bd16c commit dce9ce8

5 files changed

Lines changed: 58 additions & 88 deletions

File tree

‎clients/claude-code/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ matching events to the MCP host. Identity-free tools — `subscribe`,
148148
`unsubscribe`, `read_channel`, `read_thread`, `message_link`,
149149
`list_agents`, `list_humans`, `list_channels`, `get_channel_description`,
150150
`presence`, `resolve`, `current_identity` — run on minter credentials and work
151-
pre-acquire. `set_channel_description` is a write and needs the bound identity,
152-
which must have permission to edit the channel.
151+
pre-acquire. `set_channel_description`, `resolve_thread` and `unresolve_thread`
152+
are writes the realm attributes to their actor, so they run as the calling
153+
session's bound identity, which must have permission to edit the channel.
153154

154155
## Guidance for connected clients
155156

‎clients/claude-code/hooks-manifest.test.ts‎

Lines changed: 20 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,14 @@ import hooksManifest from './hooks/hooks.json'
1212
* call sites — a hand-maintained trigger table in the tool layer. That table is
1313
* gone: the mint decision now lives at the adapter port, where reaching for a
1414
* bound credential (`boundHttp`) IS the declaration that an identity is needed.
15-
* So the derivation is traced from there instead, which is also what
16-
* `comms-tww6` specifies:
15+
* So the derivation is traced from there instead:
1716
*
1817
* A TOOL WHOSE ADAPTER PATH REACHES `boundHttp` MUST RECEIVE `session_id`
1918
* AND BE IN THE `hooks.json` MATCHER.
2019
*
2120
* That is a stronger rule than the old one. The old test could only catch a
2221
* tool that called the wrapper and was missing from the matcher; it could not
23-
* see a tool that reached `boundHttp` while appearing in neither set — which is
24-
* exactly the live P1 that `comms-tww6` is open about.
22+
* see a tool that reached `boundHttp` while appearing in neither set.
2523
*
2624
* RECEIVES, NOT DECLARES (comms-tg70). The rule used to say DECLARE, and traced
2725
* a `session_id` property on the tool's advertised `inputSchema`. No tool
@@ -37,24 +35,25 @@ import hooksManifest from './hooks/hooks.json'
3735
* satisfies all of them by looking at nothing. Pinning the receiving set makes
3836
* the scan itself the thing under test: rename the marker and the pin fails
3937
* loudly instead of the suite passing quietly.
40-
*
41-
* KNOWN VIOLATIONS ARE NAMED, NOT PAPERED OVER. Three tools violate the rule at
42-
* HEAD (see `TWW6_EXCEPTIONS`). Fixing them means giving them the host-supplied
43-
* `session_id`, which is out of scope here. Encoding the real rule with a
44-
* visible exception list beats asserting a weaker rule that passes: the day
45-
* `comms-tww6` lands, its author deletes entries from that list and this test
46-
* proves the fix.
4738
*/
4839

4940
/**
5041
* Publisher verbs whose adapter implementation reaches `boundHttp`. Pinned
51-
* rather than parsed: two of them reach it through shared helpers
42+
* rather than parsed: three of them reach it through shared helpers
5243
* (`setThreadResolved`, `setChannelDescription`), which no line-wise scan
5344
* resolves honestly. `adapterVerbsReachingBoundHttp` below guards the pin, so a
5445
* verb that joins or leaves the seam fails this suite rather than silently
5546
* widening the set a tool has to be stamped for.
5647
*/
57-
const BOUND_VERBS = ['post', 'edit', 'react', 'unreact'] as const
48+
const BOUND_VERBS = [
49+
'post',
50+
'edit',
51+
'react',
52+
'unreact',
53+
'resolveThread',
54+
'unresolveThread',
55+
'setChannelDescription',
56+
] as const
5857

5958
/**
6059
* Declarations in `packages/zulip/adapter.ts` that call `boundHttp()`. The two
@@ -131,10 +130,9 @@ const ATTACHMENT_DEP_ADAPTER_MEMBER: Readonly<Record<string, string>> = {
131130
* Tools that reach `boundHttp` through an inbox verb while sitting outside the
132131
* matcher, so the hook never stamps them and the bind seam sees no session id.
133132
*
134-
* EMPTY, and it has to stay that way. An unstamped inbox verb is not a
135-
* `comms-tww6`-style attribution accident — it cannot inherit an earlier
136-
* call's seat, because `boundHttp` consults the binder on every call and
137-
* refuses outright when the context carries no session id. It simply FAILS.
133+
* EMPTY, and it has to stay that way. An unstamped inbox verb cannot inherit an
134+
* earlier call's seat, because `boundHttp` consults the binder on every call
135+
* and refuses outright when the context carries no session id. It simply FAILS.
138136
*
139137
* The matcher was widened to the seven tools that declare `session_id`, which
140138
* REVERSES commit `0f0e755` (PR #126) — that commit chose id-blind subscribe
@@ -147,18 +145,7 @@ const ATTACHMENT_DEP_ADAPTER_MEMBER: Readonly<Record<string, string>> = {
147145
const G5ZH3_MATCHER_PENDING = [] as const
148146

149147
/**
150-
* Tools that reach `boundHttp` while receiving no `session_id` and sitting
151-
* outside the matcher — the open P1 `comms-tww6`. They run under whatever seat
152-
* an EARLIER call happened to bind, so their attribution is inherited by
153-
* accident of ordering rather than established by the call itself.
154-
*
155-
* Delete an entry here when that tool gains `session_id`; the assertion below
156-
* then holds it to the rule.
157-
*/
158-
const TWW6_EXCEPTIONS = ['resolve_thread', 'set_channel_description', 'unresolve_thread'] as const
159-
160-
/**
161-
* Every tool that accepts a host-supplied `session_id`. EIGHT, the same eight
148+
* Every tool that accepts a host-supplied `session_id`. ELEVEN, the same eleven
162149
* the PreToolUse matcher stamps today — but the two sets answer different
163150
* questions and are allowed to diverge again: `subscribe` and `unsubscribe`
164151
* are here for a non-CC ephemeral host that supplies the UUID itself, and a
@@ -173,8 +160,11 @@ const SESSION_ID_RECEIVING_TOOLS = [
173160
'edit_message',
174161
'post',
175162
'react',
163+
'resolve_thread',
164+
'set_channel_description',
176165
'subscribe',
177166
'unreact',
167+
'unresolve_thread',
178168
'unsubscribe',
179169
'upload_file',
180170
] as const
@@ -340,7 +330,7 @@ test('the set of adapter declarations reaching boundHttp is the pinned one', asy
340330
// them reads "no tool violates this", which a scan that matches nothing
341331
// satisfies trivially — so assert first that the scan finds the set it is
342332
// supposed to find.
343-
test('the tools accepting a host-supplied session_id are exactly the pinned eight', async () => {
333+
test('the tools accepting a host-supplied session_id are exactly the pinned eleven', async () => {
344334
const facts = toolFactsFromToolsSource(await toolsSource())
345335
const receiving = [...facts]
346336
.filter(([, f]) => f.receivesSessionId)
@@ -454,18 +444,6 @@ test('the tools that bind via an inbox verb but are unstamped are exactly the re
454444
expect(unstamped).toEqual([...G5ZH3_MATCHER_PENDING])
455445
})
456446

457-
// The rule stated over ALL bound verbs, including the two helper-backed ones
458-
// the tool layer never stamps. This is the assertion `comms-tww6` closes.
459-
test('comms-tww6: the known unstamped bound-path tools are exactly the recorded exceptions', async () => {
460-
const facts = toolFactsFromToolsSource(await toolsSource())
461-
const boundHttpVerbs = new Set(['resolveThread', 'unresolveThread', 'setChannelDescription'])
462-
const unstamped = [...facts]
463-
.filter(([, f]) => [...f.verbs].some((v) => boundHttpVerbs.has(v)) && !f.receivesSessionId)
464-
.map(([name]) => name)
465-
.sort()
466-
expect(unstamped).toEqual([...TWW6_EXCEPTIONS])
467-
})
468-
469447
test('the matcher carries no tool that never reaches boundHttp and never binds', async () => {
470448
const facts = toolFactsFromToolsSource(await toolsSource())
471449
const matched = alternationToolsFromMatcher(injectSessionIdMatcher(hooksManifest))

‎clients/claude-code/hooks/hooks.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"hooks": {
33
"PreToolUse": [
44
{
5-
"matcher": "mcp__plugin_commy_commy__(post|edit_message|react|unreact|current_identity|subscribe|unsubscribe|upload_file)",
5+
"matcher": "mcp__plugin_commy_commy__(post|edit_message|react|unreact|current_identity|subscribe|unsubscribe|upload_file|resolve_thread|unresolve_thread|set_channel_description)",
66
"hooks": [
77
{
88
"type": "command",

‎packages/mcp/deployed-wiring.test.ts‎

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -412,46 +412,35 @@ test('a fresh conversation gets a fresh seat rather than inheriting the last one
412412
})
413413

414414
/**
415-
* The three verbs `comms-tww6` is open about, observed rather than argued
416-
* about. They reach the substrate's bind seam but sit outside the matcher, so
417-
* the hook never runs for them and no `session_id` reaches the server. Under
418-
* the deployed configuration they refuse — a typed refusal, not silent
419-
* attribution to whatever seat an earlier call happened to bind.
420-
*
421-
* This is a characterisation of HEAD, not an endorsement: whether they should
422-
* instead be stamped is `comms-tww6`'s fork. When that lands, this test is
423-
* what has to be rewritten, deliberately.
415+
* Resolving a thread and describing a channel both publish the actor's name
416+
* into the realm, so each must act under the seat of the conversation making
417+
* the call. The seed post binds one conversation's seat first; each verb is
418+
* then called from a different conversation and has to bind that one, not
419+
* inherit the seat already held.
424420
*/
425-
test('the tww6 verbs are outside the matcher, so the deployed configuration refuses them', async () => {
421+
test("the thread-resolution and channel-description verbs act under the calling conversation's seat", async () => {
426422
const cwd = nonRepoCwd()
427-
const manifest = await readShippedHooksManifest()
428-
const seat = await bootDeployedSeat(manifest)
423+
const seat = await bootDeployedSeat(await readShippedHooksManifest())
424+
const calls: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
425+
['resolve_thread', { channel_name: 'home', thread: 'a-topic' }],
426+
['unresolve_thread', { channel_name: 'home', thread: 'a-topic' }],
427+
['set_channel_description', { channel_name: 'home', description: 'a new description' }],
428+
]
429429
try {
430-
await seat.callAsClaudeCode(
431-
'post',
432-
{ channel_name: 'home', body: 'seed', thread: 'a-topic' },
433-
{ sessionId: SID_CONVERSATION, cwd: cwd.path },
434-
)
435-
for (const tool of ['resolve_thread', 'unresolve_thread'] as const) {
436-
const args = await argumentsAfterPreToolUse(manifest, {
437-
session_id: SID_CONVERSATION,
430+
for (const [tool, args] of calls) {
431+
await seat.callAsClaudeCode(
432+
'post',
433+
{ channel_name: 'home', body: 'seed', thread: 'a-topic' },
434+
{ sessionId: SID_CONVERSATION, cwd: cwd.path },
435+
)
436+
expect(await seat.boundName()).toBe('cc-aaaaaaaa')
437+
const result = await seat.callAsClaudeCode(tool, args, {
438+
sessionId: SID_AFTER_CLEAR,
438439
cwd: cwd.path,
439-
tool_name: toolNameAsClaudeCodeSeesIt(tool),
440-
tool_input: { channel_name: 'home', thread: 'a-topic' },
441440
})
442-
expect(args).not.toHaveProperty('session_id')
443-
const refusal = await refusalMessage(
444-
seat.callAsClaudeCode(tool, { channel_name: 'home', thread: 'a-topic' }),
445-
)
446-
expect(refusal).toContain('UnboundEphemeralSession')
441+
expect(result.isError).toBeFalsy()
442+
expect(await seat.boundName()).toBe('cc-bbbbbbbb')
447443
}
448-
const description = await refusalMessage(
449-
seat.callAsClaudeCode('set_channel_description', {
450-
channel_name: 'home',
451-
description: 'a new description',
452-
}),
453-
)
454-
expect(description).toContain('UnboundEphemeralSession')
455444
} finally {
456445
await seat.shutdown()
457446
cwd.remove()
@@ -497,13 +486,9 @@ const DEPLOYED_OUTCOMES: Readonly<Record<string, DeployedOutcome>> = {
497486
unreact: 'usable',
498487
download_file: 'usable',
499488
upload_file: 'usable',
500-
// The three `comms-tww6` is open about: they reach the substrate's bind seam
501-
// but sit outside the PreToolUse matcher, so no session id ever reaches the
502-
// server on their behalf. Recorded as observed, not as endorsed — when
503-
// `comms-tww6` is decided, these three entries are what changes.
504-
resolve_thread: 'refused-for-want-of-identity',
505-
unresolve_thread: 'refused-for-want-of-identity',
506-
set_channel_description: 'refused-for-want-of-identity',
489+
resolve_thread: 'usable',
490+
unresolve_thread: 'usable',
491+
set_channel_description: 'usable',
507492
}
508493

509494
test('every shipped tool, driven through the deployed chain, lands where the table says', async () => {

‎packages/mcp/tools.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,8 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
509509
const projectForCwd = deps.projectForCwd ?? (() => Effect.succeedNone)
510510
/**
511511
* The tools that accept a host-supplied `session_id` (see
512-
* {@link ToolDef.hostSuppliedArgs}). EIGHT tools carry it. That is the same
513-
* eight Claude Code's PreToolUse matcher stamps today, but the two sets are
512+
* {@link ToolDef.hostSuppliedArgs}). ELEVEN tools carry it. That is the same
513+
* eleven Claude Code's PreToolUse matcher stamps today, but the two sets are
514514
* separate questions and are allowed to differ: this one also serves the
515515
* non-CC ephemeral host that supplies the UUID itself, which is a
516516
* listen-first seat's only route to an identity. A host stamping the arg on
@@ -1081,10 +1081,12 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
10811081
properties: {
10821082
channel_name: { type: 'string', description: 'Channel the thread lives in' },
10831083
thread: { type: 'string', description: 'Thread / topic name within the channel' },
1084+
cwd: cwdField,
10841085
},
10851086
required: ['channel_name', 'thread'],
10861087
additionalProperties: false,
10871088
},
1089+
hostSuppliedArgs: hostSuppliedSessionId,
10881090
handler: async (args) => {
10891091
const run = runFor(args)
10901092
await run(
@@ -1108,10 +1110,12 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
11081110
properties: {
11091111
channel_name: { type: 'string', description: 'Channel the thread lives in' },
11101112
thread: { type: 'string', description: 'Thread / topic name within the channel' },
1113+
cwd: cwdField,
11111114
},
11121115
required: ['channel_name', 'thread'],
11131116
additionalProperties: false,
11141117
},
1118+
hostSuppliedArgs: hostSuppliedSessionId,
11151119
handler: async (args) => {
11161120
const run = runFor(args)
11171121
await run(
@@ -1163,10 +1167,12 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
11631167
type: 'string',
11641168
description: "The channel's new description; empty string clears it",
11651169
},
1170+
cwd: cwdField,
11661171
},
11671172
required: ['channel_name', 'description'],
11681173
additionalProperties: false,
11691174
},
1175+
hostSuppliedArgs: hostSuppliedSessionId,
11701176
handler: async (args) => {
11711177
const run = runFor(args)
11721178
await run(

0 commit comments

Comments
 (0)