typeclaw 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot.ts +238 -20
- package/src/channels/adapters/github/inbound.ts +10 -0
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/engagement.ts +16 -19
- package/src/channels/router.ts +151 -16
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +12 -2
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from './tool-result-budget'
|
|
50
50
|
import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
|
|
51
51
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
52
|
+
import { createChannelReactTool } from './tools/channel-react'
|
|
52
53
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
53
54
|
import { createChannelSendTool } from './tools/channel-send'
|
|
54
55
|
import { createGrantRoleTool } from './tools/grant-role'
|
|
@@ -532,6 +533,12 @@ export function buildChannelTools(
|
|
|
532
533
|
tools.push(createChannelReplyTool({ router: channelRouter, origin: channelOrigin }))
|
|
533
534
|
tools.push(createChannelHistoryTool({ router: channelRouter, origin: channelOrigin }))
|
|
534
535
|
tools.push(createChannelSendTool({ router: channelRouter, origin: channelOrigin }))
|
|
536
|
+
tools.push(
|
|
537
|
+
createChannelReactTool({
|
|
538
|
+
router: channelRouter,
|
|
539
|
+
origin: { ...channelOrigin, ...(origin.reactionRef !== undefined ? { reactionRef: origin.reactionRef } : {}) },
|
|
540
|
+
}),
|
|
541
|
+
)
|
|
535
542
|
tools.push(
|
|
536
543
|
createChannelFetchAttachmentTool({
|
|
537
544
|
router: channelRouter,
|
|
@@ -19,6 +19,10 @@ export type LiveSubagent = {
|
|
|
19
19
|
sessionId: string
|
|
20
20
|
subagentName: string
|
|
21
21
|
parentSessionId?: string
|
|
22
|
+
// Role that resolved at spawn time, captured for the provenance cap on
|
|
23
|
+
// subagent_output/subagent_cancel. Absent when no permission service was
|
|
24
|
+
// active at spawn, in which case the cap fails closed.
|
|
25
|
+
spawnedByRole?: string
|
|
22
26
|
startedAt: number
|
|
23
27
|
status: SubagentStatus
|
|
24
28
|
completion?: SubagentCompletion
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
|
|
2
2
|
import type { AdapterId } from '@/channels/schema'
|
|
3
|
+
import type { ReactionRef } from '@/channels/types'
|
|
3
4
|
|
|
4
5
|
export type ChannelParticipant = {
|
|
5
6
|
authorId: string
|
|
@@ -38,6 +39,7 @@ export type SessionOrigin =
|
|
|
38
39
|
chatName?: string
|
|
39
40
|
thread: string | null
|
|
40
41
|
lastInboundAuthorId?: string
|
|
42
|
+
reactionRef?: ReactionRef
|
|
41
43
|
participants?: readonly ChannelParticipant[]
|
|
42
44
|
membership?: MembershipCount
|
|
43
45
|
}
|
|
@@ -301,6 +303,13 @@ function renderChannelOrigin(
|
|
|
301
303
|
'audience: post the answer (or review summary) itself, never a status',
|
|
302
304
|
'line about having posted it elsewhere. A narrated "Posted review result',
|
|
303
305
|
'for PR #N: …" inside the PR is exactly the failure to avoid.',
|
|
306
|
+
'',
|
|
307
|
+
'**Do not post an "On it" acknowledgment comment.** The runtime already',
|
|
308
|
+
'adds an :eyes: reaction to the triggering item the moment it engages, so a',
|
|
309
|
+
'separate "looking into this" comment is redundant noise on the PR. If you',
|
|
310
|
+
'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
|
|
311
|
+
'(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
|
|
312
|
+
'for the actual substantive answer.',
|
|
304
313
|
)
|
|
305
314
|
}
|
|
306
315
|
|
|
@@ -339,12 +348,21 @@ function renderChannelOrigin(
|
|
|
339
348
|
'`channel_reply` call, not narration. This includes acks.',
|
|
340
349
|
'',
|
|
341
350
|
'**One substantive reply per inbound.** If the answer needs more than one',
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
...(origin.adapter === 'github'
|
|
352
|
+
? [
|
|
353
|
+
'tool call, keep working and post the answer with a single final',
|
|
354
|
+
'`channel_reply`. Do not post an "On it" ack comment first — the runtime',
|
|
355
|
+
'already added an :eyes: reaction on engage; use `channel_react` if you',
|
|
356
|
+
'want to acknowledge explicitly. The answer is your reply.',
|
|
357
|
+
]
|
|
358
|
+
: [
|
|
359
|
+
'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
|
|
360
|
+
'continue: true })`, keep working, then send the answer with a final',
|
|
361
|
+
'`channel_reply`. The ack is not your reply; the answer is. Once the answer',
|
|
362
|
+
'lands, end your turn. The `continue: true` is not optional on that ack:',
|
|
363
|
+
'without it the turn ends the instant the ack lands and the rest of your',
|
|
364
|
+
'work — the fetch, the subagent, the actual answer — is silently dropped.',
|
|
365
|
+
]),
|
|
348
366
|
'',
|
|
349
367
|
'**Backgrounded work does not end the obligation.** If you spawn a',
|
|
350
368
|
'subagent with `run_in_background: true` to answer the current inbound,',
|
|
@@ -400,15 +418,19 @@ function renderMembershipSummary(
|
|
|
400
418
|
if (membership === undefined) return null
|
|
401
419
|
|
|
402
420
|
const total = membership.humans + membership.bots
|
|
421
|
+
// Exact Discord counts are channel-scoped (filtered by who can VIEW_CHANNEL),
|
|
422
|
+
// so the count is the channel's room, not the guild. The truncated branch is
|
|
423
|
+
// history-derived recent speakers, which is not a channel-membership claim,
|
|
424
|
+
// so the caveat would mislead there.
|
|
425
|
+
const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
|
|
403
426
|
const caveat =
|
|
404
|
-
origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
|
|
405
|
-
? ' (
|
|
427
|
+
isExact && origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
|
|
428
|
+
? ' (This counts only members who can view this channel, not the whole guild.)'
|
|
406
429
|
: ''
|
|
407
|
-
const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
|
|
408
430
|
if (isExact) {
|
|
409
431
|
return `This channel has ${total} members: ${membership.humans} humans, ${membership.bots} bots.${caveat} The 10 most recent speakers are listed below.`
|
|
410
432
|
}
|
|
411
|
-
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap)
|
|
433
|
+
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap). The 10 most recent speakers are listed below.`
|
|
412
434
|
}
|
|
413
435
|
|
|
414
436
|
function renderMentionGuidance(
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
5
|
+
import type { AdapterId } from '@/channels/schema'
|
|
6
|
+
import type { ReactionRef } from '@/channels/types'
|
|
7
|
+
|
|
8
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
9
|
+
import { TOOL_RESULT_PREFIX } from './channel-reply'
|
|
10
|
+
|
|
11
|
+
export type ChannelReactOrigin = {
|
|
12
|
+
adapter: AdapterId
|
|
13
|
+
workspace: string
|
|
14
|
+
chat: string
|
|
15
|
+
thread: string | null
|
|
16
|
+
reactionRef?: ReactionRef
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CreateChannelReactToolOptions = {
|
|
20
|
+
router: ChannelRouter
|
|
21
|
+
origin: ChannelReactOrigin
|
|
22
|
+
logger?: ChannelToolLogger
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createChannelReactTool({
|
|
26
|
+
router,
|
|
27
|
+
origin,
|
|
28
|
+
logger = consoleChannelLogger,
|
|
29
|
+
}: CreateChannelReactToolOptions) {
|
|
30
|
+
return defineTool({
|
|
31
|
+
name: 'channel_react',
|
|
32
|
+
label: 'Channel React',
|
|
33
|
+
description:
|
|
34
|
+
'Add an emoji reaction to the message that triggered this turn — a lightweight acknowledgment that does not post a comment. ' +
|
|
35
|
+
'On GitHub this reacts to the triggering issue/PR/comment (e.g. :eyes: to signal "I am looking at this"). ' +
|
|
36
|
+
'Use this instead of a textual "on it" reply when a reaction is enough. Pass the bare emoji name, no colons.',
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
emoji: Type.String({
|
|
39
|
+
description: 'Bare emoji name, no surrounding colons. e.g. "eyes", "+1", "rocket", "heart".',
|
|
40
|
+
minLength: 1,
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
async execute(_toolCallId, params) {
|
|
45
|
+
const deny = (error: string) => {
|
|
46
|
+
logger.warn(formatChannelToolFailure('channel_react', error))
|
|
47
|
+
const details: { ok: boolean; error?: string } = { ok: false, error }
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_react denied: ${error}` }],
|
|
50
|
+
details,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (origin.reactionRef === undefined) return deny('this conversation has no message to react to')
|
|
55
|
+
|
|
56
|
+
const result = await router.react({
|
|
57
|
+
adapter: origin.adapter,
|
|
58
|
+
workspace: origin.workspace,
|
|
59
|
+
chat: origin.chat,
|
|
60
|
+
thread: origin.thread,
|
|
61
|
+
reactionRef: origin.reactionRef,
|
|
62
|
+
emoji: params.emoji,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (!result.ok) return deny(`${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`)
|
|
66
|
+
|
|
67
|
+
const details: { ok: boolean; error?: string } = { ok: true }
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: 'text' as const,
|
|
72
|
+
text: `${TOOL_RESULT_PREFIX}reacted with :${params.emoji}: on ${origin.adapter}:${origin.workspace}/${origin.chat}`,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
details,
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -129,6 +129,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
129
129
|
sessionId: resolvedHandle.sessionId ?? '<pending>',
|
|
130
130
|
subagentName,
|
|
131
131
|
parentSessionId,
|
|
132
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
132
133
|
startedAt,
|
|
133
134
|
status: 'running' as const,
|
|
134
135
|
abort: resolvedHandle.abort,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PermissionService } from '@/permissions'
|
|
2
|
+
|
|
3
|
+
import type { LiveSubagent, LiveSubagentRegistry } from '../live-subagents'
|
|
4
|
+
import type { SessionOrigin } from '../session-origin'
|
|
5
|
+
|
|
6
|
+
export type SubagentAccessPermission = 'subagent.output' | 'subagent.cancel'
|
|
7
|
+
|
|
8
|
+
export type SubagentAccessResult = { ok: true; live: LiveSubagent } | { ok: false; message: string }
|
|
9
|
+
|
|
10
|
+
export type AuthorizeLiveSubagentAccessArgs = {
|
|
11
|
+
permissions: PermissionService | undefined
|
|
12
|
+
origin: SessionOrigin | undefined
|
|
13
|
+
liveRegistry: LiveSubagentRegistry
|
|
14
|
+
taskId: string
|
|
15
|
+
permission: SubagentAccessPermission
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Authorizes a single subagent_output/subagent_cancel call and resolves the
|
|
19
|
+
// live entry in one place so the two tools cannot drift. Caps access to the
|
|
20
|
+
// requester's role: the caller must hold the permission AND resolve to a role
|
|
21
|
+
// at least as high as the role that spawned the subagent.
|
|
22
|
+
//
|
|
23
|
+
// The ordering closes an existence oracle: the task-independent base-permission
|
|
24
|
+
// check runs BEFORE any registry lookup, and for non-owner callers an absent
|
|
25
|
+
// task, a capped task, and a task with missing provenance all collapse to one
|
|
26
|
+
// identical denial — so a lower-role caller cannot probe which task IDs are
|
|
27
|
+
// live. Only `owner` (the trust root, which outranks every spawner) learns the
|
|
28
|
+
// truthful `Unknown task_id` for a genuine miss. The cap fails closed.
|
|
29
|
+
export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArgs): SubagentAccessResult {
|
|
30
|
+
const { permissions, origin, liveRegistry, taskId, permission } = args
|
|
31
|
+
|
|
32
|
+
if (permissions === undefined) {
|
|
33
|
+
const live = liveRegistry.get(taskId)
|
|
34
|
+
if (live === undefined) {
|
|
35
|
+
return { ok: false, message: `Unknown task_id: ${taskId}.` }
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, live }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!permissions.has(origin, permission)) {
|
|
41
|
+
return { ok: false, message: `${permission} denied: insufficient permissions` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const requesterRole = permissions.resolveRole(origin)
|
|
45
|
+
const accessAll = requesterRole === 'owner'
|
|
46
|
+
const opaqueDenial = `${permission} denied: unknown task_id or insufficient role`
|
|
47
|
+
|
|
48
|
+
const live = liveRegistry.get(taskId)
|
|
49
|
+
if (live === undefined) {
|
|
50
|
+
return { ok: false, message: accessAll ? `Unknown task_id: ${taskId}.` : opaqueDenial }
|
|
51
|
+
}
|
|
52
|
+
if (accessAll) {
|
|
53
|
+
return { ok: true, live }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spawnedByRole = live.spawnedByRole
|
|
57
|
+
if (spawnedByRole === undefined) {
|
|
58
|
+
return { ok: false, message: opaqueDenial }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cmp = permissions.compareRoleSeverity(requesterRole, spawnedByRole)
|
|
62
|
+
if (cmp === undefined || cmp < 0) {
|
|
63
|
+
return { ok: false, message: opaqueDenial }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ok: true, live }
|
|
67
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
|
|
|
5
5
|
|
|
6
6
|
import type { LiveSubagentRegistry } from '../live-subagents'
|
|
7
7
|
import type { SessionOrigin } from '../session-origin'
|
|
8
|
+
import { authorizeLiveSubagentAccess } from './subagent-access'
|
|
8
9
|
|
|
9
10
|
export type SubagentCancelToolDetails =
|
|
10
11
|
| { ok: true; taskId: string; subagent: string; alreadyDone: boolean }
|
|
@@ -33,13 +34,17 @@ export function createSubagentCancelTool(options: CreateSubagentCancelToolOption
|
|
|
33
34
|
}),
|
|
34
35
|
|
|
35
36
|
async execute(_toolCallId, params): Promise<ToolReturn> {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
const access = authorizeLiveSubagentAccess({
|
|
38
|
+
permissions,
|
|
39
|
+
origin: getOrigin(),
|
|
40
|
+
liveRegistry,
|
|
41
|
+
taskId: params.task_id,
|
|
42
|
+
permission: 'subagent.cancel',
|
|
43
|
+
})
|
|
44
|
+
if (!access.ok) {
|
|
45
|
+
return errorResult(access.message)
|
|
42
46
|
}
|
|
47
|
+
const live = access.live
|
|
43
48
|
if (live.status !== 'running') {
|
|
44
49
|
const details: SubagentCancelToolDetails = {
|
|
45
50
|
ok: true,
|
|
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
|
|
|
5
5
|
|
|
6
6
|
import type { LiveSubagentRegistry, StatusSnapshot, SubagentProgressEvent } from '../live-subagents'
|
|
7
7
|
import type { SessionOrigin } from '../session-origin'
|
|
8
|
+
import { authorizeLiveSubagentAccess } from './subagent-access'
|
|
8
9
|
|
|
9
10
|
export type SubagentOutputToolDetails =
|
|
10
11
|
| {
|
|
@@ -64,8 +65,15 @@ export function createSubagentOutputTool(options: CreateSubagentOutputToolOption
|
|
|
64
65
|
}),
|
|
65
66
|
|
|
66
67
|
async execute(_toolCallId, params) {
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
const access = authorizeLiveSubagentAccess({
|
|
69
|
+
permissions,
|
|
70
|
+
origin: getOrigin(),
|
|
71
|
+
liveRegistry,
|
|
72
|
+
taskId: params.task_id,
|
|
73
|
+
permission: 'subagent.output',
|
|
74
|
+
})
|
|
75
|
+
if (!access.ok) {
|
|
76
|
+
return errorResult(access.message)
|
|
69
77
|
}
|
|
70
78
|
const snap = liveRegistry.snapshot(params.task_id, now())
|
|
71
79
|
if (snap === undefined) {
|
|
@@ -148,6 +148,116 @@ type DiscordGuildPreview = {
|
|
|
148
148
|
|
|
149
149
|
type DiscordGuildMember = {
|
|
150
150
|
user?: { id?: string; bot?: boolean }
|
|
151
|
+
roles?: string[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type DiscordPermissionOverwrite = {
|
|
155
|
+
id?: string
|
|
156
|
+
type?: number
|
|
157
|
+
allow?: string
|
|
158
|
+
deny?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Discord channel `type` values that are threads. A thread's own
|
|
162
|
+
// permission_overwrites are empty and its `parent_id` points at its parent
|
|
163
|
+
// channel (not a category), so the normal "compute from this channel's
|
|
164
|
+
// overwrites" path would treat a thread as fully public. We refuse to count
|
|
165
|
+
// threads channel-scoped and fall back instead — fail-closed.
|
|
166
|
+
const DISCORD_THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([10, 11, 12])
|
|
167
|
+
|
|
168
|
+
type DiscordChannelObject = {
|
|
169
|
+
type?: number
|
|
170
|
+
permission_overwrites?: DiscordPermissionOverwrite[]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type DiscordRole = {
|
|
174
|
+
id?: string
|
|
175
|
+
permissions?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type DiscordGuildObject = {
|
|
179
|
+
owner_id?: string
|
|
180
|
+
roles?: DiscordRole[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Discord permission bits. Discord serialises permission bitsets as decimal
|
|
184
|
+
// STRINGS that can exceed Number.MAX_SAFE_INTEGER, so all bit math is done in
|
|
185
|
+
// BigInt. Only the two bits this resolver needs are named.
|
|
186
|
+
const DISCORD_PERMISSION_VIEW_CHANNEL = 0x400n
|
|
187
|
+
const DISCORD_PERMISSION_ADMINISTRATOR = 0x8n
|
|
188
|
+
|
|
189
|
+
function parsePermissionBits(raw: string | undefined): bigint {
|
|
190
|
+
if (raw === undefined || raw === '') return 0n
|
|
191
|
+
try {
|
|
192
|
+
return BigInt(raw)
|
|
193
|
+
} catch {
|
|
194
|
+
return 0n
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Computes whether a single member can VIEW_CHANNEL on a normal guild channel,
|
|
199
|
+
// following Discord's documented resolution order:
|
|
200
|
+
// base = @everyone role perms OR all of the member's role perms
|
|
201
|
+
// → owner or ADMINISTRATOR short-circuits to visible
|
|
202
|
+
// → @everyone channel overwrite (clear deny bits, then set allow bits)
|
|
203
|
+
// → all matching role overwrites combined (OR every deny, OR every allow;
|
|
204
|
+
// apply denies then allows)
|
|
205
|
+
// → member-specific overwrite (clear deny, set allow)
|
|
206
|
+
// Returns null when the data is insufficient to decide (a member references a
|
|
207
|
+
// role absent from the guild role map), so the caller can fail closed rather
|
|
208
|
+
// than guess.
|
|
209
|
+
function memberCanViewChannel(args: {
|
|
210
|
+
guildId: string
|
|
211
|
+
ownerId: string | undefined
|
|
212
|
+
rolePermissions: ReadonlyMap<string, bigint>
|
|
213
|
+
overwrites: ReadonlyMap<string, { allow: bigint; deny: bigint }>
|
|
214
|
+
memberId: string | undefined
|
|
215
|
+
memberRoleIds: readonly string[]
|
|
216
|
+
}): boolean | null {
|
|
217
|
+
const { guildId, ownerId, rolePermissions, overwrites, memberId, memberRoleIds } = args
|
|
218
|
+
|
|
219
|
+
if (memberId !== undefined && ownerId !== undefined && memberId === ownerId) return true
|
|
220
|
+
|
|
221
|
+
// Discord's `@everyone` role id always equals the guild id, so the guild id
|
|
222
|
+
// keys both the base @everyone permissions and the @everyone overwrite.
|
|
223
|
+
let base = rolePermissions.get(guildId) ?? 0n
|
|
224
|
+
for (const roleId of memberRoleIds) {
|
|
225
|
+
const perms = rolePermissions.get(roleId)
|
|
226
|
+
if (perms === undefined) return null
|
|
227
|
+
base |= perms
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if ((base & DISCORD_PERMISSION_ADMINISTRATOR) !== 0n) return true
|
|
231
|
+
|
|
232
|
+
let perms = base
|
|
233
|
+
|
|
234
|
+
const everyoneOverwrite = overwrites.get(guildId)
|
|
235
|
+
if (everyoneOverwrite !== undefined) {
|
|
236
|
+
perms &= ~everyoneOverwrite.deny
|
|
237
|
+
perms |= everyoneOverwrite.allow
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let roleDeny = 0n
|
|
241
|
+
let roleAllow = 0n
|
|
242
|
+
for (const roleId of memberRoleIds) {
|
|
243
|
+
const overwrite = overwrites.get(roleId)
|
|
244
|
+
if (overwrite !== undefined) {
|
|
245
|
+
roleDeny |= overwrite.deny
|
|
246
|
+
roleAllow |= overwrite.allow
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
perms &= ~roleDeny
|
|
250
|
+
perms |= roleAllow
|
|
251
|
+
|
|
252
|
+
if (memberId !== undefined) {
|
|
253
|
+
const memberOverwrite = overwrites.get(memberId)
|
|
254
|
+
if (memberOverwrite !== undefined) {
|
|
255
|
+
perms &= ~memberOverwrite.deny
|
|
256
|
+
perms |= memberOverwrite.allow
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (perms & DISCORD_PERMISSION_VIEW_CHANNEL) !== 0n
|
|
151
261
|
}
|
|
152
262
|
|
|
153
263
|
export function createDiscordMembershipResolver(deps: {
|
|
@@ -207,28 +317,136 @@ export function createDiscordMembershipResolver(deps: {
|
|
|
207
317
|
return members.failure
|
|
208
318
|
}
|
|
209
319
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
320
|
+
// Guild `/members` returns the whole guild, not the channel. A bot or
|
|
321
|
+
// human that cannot VIEW_CHANNEL the target channel is not in the room and
|
|
322
|
+
// not a prompt-injection surface, so it must not be counted — otherwise a
|
|
323
|
+
// private channel of "operator + agent bot" inside a multi-bot guild reads
|
|
324
|
+
// as bots>1 and the grant_role relaxation (provesOnlyAgentBotPresent)
|
|
325
|
+
// false-refuses. Resolve channel visibility for each member; on ANY
|
|
326
|
+
// inability to decide, fall back rather than over- or under-count.
|
|
327
|
+
// `key.thread ?? key.chat` mirrors the history callback's channel-id
|
|
328
|
+
// resolution: today Discord stores a thread's own snowflake in `chat` with
|
|
329
|
+
// `thread` null, but a future caller that passes `chat=parent,
|
|
330
|
+
// thread=threadId` must scope visibility to the thread, not the parent.
|
|
331
|
+
const scoped = await scopeMembersToChannel({
|
|
332
|
+
fetchFn,
|
|
333
|
+
token: deps.token,
|
|
334
|
+
logger: deps.logger,
|
|
335
|
+
guildId: key.workspace,
|
|
336
|
+
channelId: key.thread ?? key.chat,
|
|
337
|
+
members: members.value,
|
|
338
|
+
})
|
|
339
|
+
if (scoped === 'fallback') return await fallback()
|
|
340
|
+
|
|
341
|
+
return scoped.everyHumanIdentified
|
|
342
|
+
? {
|
|
343
|
+
humans: scoped.humans,
|
|
344
|
+
bots: scoped.bots,
|
|
345
|
+
fetchedAt: now(),
|
|
346
|
+
truncated: false,
|
|
347
|
+
humanMemberIds: scoped.humanMemberIds,
|
|
348
|
+
}
|
|
349
|
+
: { humans: scoped.humans, bots: scoped.bots, fetchedAt: now(), truncated: false }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type ScopedMembership = {
|
|
354
|
+
humans: number
|
|
355
|
+
bots: number
|
|
356
|
+
humanMemberIds: string[]
|
|
357
|
+
everyHumanIdentified: boolean
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Filters a guild member list down to those who can VIEW_CHANNEL the target
|
|
361
|
+
// channel, then counts humans/bots over that visible set. Returns 'fallback'
|
|
362
|
+
// when channel visibility cannot be computed completely (channel/guild fetch
|
|
363
|
+
// failure, a member referencing an unknown role, or a thread channel whose
|
|
364
|
+
// visibility this resolver does not model) — the caller then derives from
|
|
365
|
+
// history (truncated:true), keeping every security consumer fail-closed.
|
|
366
|
+
async function scopeMembersToChannel(args: {
|
|
367
|
+
fetchFn: typeof fetch
|
|
368
|
+
token: string
|
|
369
|
+
logger: DiscordBotAdapterLogger
|
|
370
|
+
guildId: string
|
|
371
|
+
channelId: string
|
|
372
|
+
members: readonly DiscordGuildMember[]
|
|
373
|
+
}): Promise<ScopedMembership | 'fallback'> {
|
|
374
|
+
const { fetchFn, token, logger, guildId, channelId, members } = args
|
|
375
|
+
|
|
376
|
+
const [channel, guild] = await Promise.all([
|
|
377
|
+
fetchDiscordJson<DiscordChannelObject>(fetchFn, `${DISCORD_API_BASE}/channels/${channelId}`, token),
|
|
378
|
+
fetchDiscordJson<DiscordGuildObject>(fetchFn, `${DISCORD_API_BASE}/guilds/${guildId}`, token),
|
|
379
|
+
])
|
|
380
|
+
if (!channel.ok) {
|
|
381
|
+
logger.warn(
|
|
382
|
+
`[discord-bot] membership channel=${channelId} fetch failed: ${channel.reason}; deriving from recent message authors`,
|
|
383
|
+
)
|
|
384
|
+
return 'fallback'
|
|
385
|
+
}
|
|
386
|
+
if (!guild.ok) {
|
|
387
|
+
logger.warn(
|
|
388
|
+
`[discord-bot] membership guild=${guildId} fetch failed: ${guild.reason}; deriving from recent message authors`,
|
|
389
|
+
)
|
|
390
|
+
return 'fallback'
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (channel.value.type !== undefined && DISCORD_THREAD_CHANNEL_TYPES.has(channel.value.type)) {
|
|
394
|
+
// A thread inherits visibility from its parent channel and (for private
|
|
395
|
+
// threads) an explicit member list; we do not model either here. Fall
|
|
396
|
+
// back rather than treat the thread as world-visible.
|
|
397
|
+
logger.warn(
|
|
398
|
+
`[discord-bot] membership channel=${channelId} is a thread; deriving from recent message authors instead of channel-scoped enumeration`,
|
|
399
|
+
)
|
|
400
|
+
return 'fallback'
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rolePermissions = new Map<string, bigint>()
|
|
404
|
+
for (const role of guild.value.roles ?? []) {
|
|
405
|
+
if (role.id !== undefined) rolePermissions.set(role.id, parsePermissionBits(role.permissions))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const overwrites = new Map<string, { allow: bigint; deny: bigint }>()
|
|
409
|
+
for (const overwrite of channel.value.permission_overwrites ?? []) {
|
|
410
|
+
if (overwrite.id === undefined) continue
|
|
411
|
+
overwrites.set(overwrite.id, {
|
|
412
|
+
allow: parsePermissionBits(overwrite.allow),
|
|
413
|
+
deny: parsePermissionBits(overwrite.deny),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let humans = 0
|
|
418
|
+
let bots = 0
|
|
419
|
+
const humanMemberIds: string[] = []
|
|
420
|
+
let everyHumanIdentified = true
|
|
421
|
+
|
|
422
|
+
for (const member of members) {
|
|
423
|
+
const visible = memberCanViewChannel({
|
|
424
|
+
guildId,
|
|
425
|
+
ownerId: guild.value.owner_id,
|
|
426
|
+
rolePermissions,
|
|
427
|
+
overwrites,
|
|
428
|
+
memberId: member.user?.id,
|
|
429
|
+
memberRoleIds: member.roles ?? [],
|
|
430
|
+
})
|
|
431
|
+
if (visible === null) {
|
|
432
|
+
logger.warn(
|
|
433
|
+
`[discord-bot] membership channel=${channelId} member references unknown role; deriving from recent message authors`,
|
|
434
|
+
)
|
|
435
|
+
return 'fallback'
|
|
223
436
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
437
|
+
if (!visible) continue
|
|
438
|
+
|
|
439
|
+
if (member.user?.bot === true) {
|
|
440
|
+
bots++
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
humans++
|
|
444
|
+
const userId = member.user?.id
|
|
445
|
+
if (userId === undefined) everyHumanIdentified = false
|
|
446
|
+
else humanMemberIds.push(userId)
|
|
231
447
|
}
|
|
448
|
+
|
|
449
|
+
return { humans, bots, humanMemberIds, everyHumanIdentified }
|
|
232
450
|
}
|
|
233
451
|
|
|
234
452
|
type DiscordFetchResult<T> =
|