typeclaw 0.18.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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +9 -1
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/model-overrides.ts +77 -0
  5. package/src/agent/plugin-tools.ts +53 -4
  6. package/src/agent/session-origin.ts +32 -10
  7. package/src/agent/tools/channel-react.ts +79 -0
  8. package/src/agent/tools/grant-role.ts +102 -8
  9. package/src/agent/tools/spawn-subagent.ts +1 -0
  10. package/src/agent/tools/subagent-access.ts +67 -0
  11. package/src/agent/tools/subagent-cancel.ts +11 -6
  12. package/src/agent/tools/subagent-output.ts +10 -2
  13. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  14. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  15. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  17. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  18. package/src/channels/adapters/discord-bot.ts +242 -7
  19. package/src/channels/adapters/github/inbound.ts +40 -55
  20. package/src/channels/adapters/github/index.ts +89 -18
  21. package/src/channels/adapters/github/membership.ts +4 -0
  22. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  23. package/src/channels/adapters/github/reactions.ts +142 -0
  24. package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
  25. package/src/channels/adapters/slack-bot.ts +4 -4
  26. package/src/channels/commands.ts +10 -0
  27. package/src/channels/engagement.ts +30 -2
  28. package/src/channels/github-token-bridge.ts +42 -0
  29. package/src/channels/index.ts +6 -0
  30. package/src/channels/manager.ts +6 -0
  31. package/src/channels/membership.ts +9 -0
  32. package/src/channels/router.ts +295 -42
  33. package/src/channels/types.ts +42 -0
  34. package/src/cli/inspect.ts +3 -0
  35. package/src/cli/ui.ts +6 -0
  36. package/src/commands/index.ts +54 -4
  37. package/src/init/dockerfile.ts +60 -0
  38. package/src/init/validate-api-key.ts +15 -1
  39. package/src/inspect/loop.ts +12 -1
  40. package/src/permissions/permissions.ts +24 -0
  41. package/src/plugin/context.ts +8 -0
  42. package/src/plugin/manager.ts +3 -0
  43. package/src/plugin/types.ts +6 -0
  44. package/src/run/bundled-plugins.ts +9 -0
  45. package/src/run/index.ts +4 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
@@ -42,6 +42,7 @@ import {
42
42
  // commands here is the documented extension point: declare the entry here,
43
43
  // then add the matching handler in createChannelRouter's command registry.
44
44
  const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
45
+ { name: 'help', description: 'List available commands' },
45
46
  { name: 'stop', description: 'Abort the current turn in this channel' },
46
47
  ]
47
48
  const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
@@ -147,6 +148,116 @@ type DiscordGuildPreview = {
147
148
 
148
149
  type DiscordGuildMember = {
149
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
150
261
  }
151
262
 
152
263
  export function createDiscordMembershipResolver(deps: {
@@ -206,14 +317,136 @@ export function createDiscordMembershipResolver(deps: {
206
317
  return members.failure
207
318
  }
208
319
 
209
- let bots = 0
210
- let humans = 0
211
- for (const member of members.value) {
212
- if (member.user?.bot === true) bots++
213
- else humans++
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'
436
+ }
437
+ if (!visible) continue
438
+
439
+ if (member.user?.bot === true) {
440
+ bots++
441
+ continue
214
442
  }
215
- return { humans, bots, fetchedAt: now(), truncated: false }
443
+ humans++
444
+ const userId = member.user?.id
445
+ if (userId === undefined) everyHumanIdentified = false
446
+ else humanMemberIds.push(userId)
216
447
  }
448
+
449
+ return { humans, bots, humanMemberIds, everyHumanIdentified }
217
450
  }
218
451
 
219
452
  type DiscordFetchResult<T> =
@@ -508,7 +741,9 @@ export function createInteractionHandler(
508
741
  })
509
742
  const replyContent =
510
743
  result.kind === 'handled'
511
- ? STOP_REPLY_ABORTED
744
+ ? // Dynamic commands (e.g. /help) carry their own reply; static
745
+ // control commands (/stop) fall back to the fixed confirmation.
746
+ (result.reply ?? STOP_REPLY_ABORTED)
512
747
  : result.kind === 'no-live-session'
513
748
  ? STOP_REPLY_NO_LIVE_SESSION
514
749
  : result.kind === 'permission-denied'
@@ -4,6 +4,7 @@ import type { InboundMessage } from '@/channels/types'
4
4
 
5
5
  import type { DeliveryDedup } from './dedup'
6
6
  import { isGithubEventAllowed } from './event-allowlist'
7
+ import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
7
8
 
8
9
  export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
9
10
 
@@ -13,8 +14,8 @@ export type GithubWebhookHandlerOptions = {
13
14
  allowlist: () => readonly string[]
14
15
  selfId: () => string | null
15
16
  selfLogin: () => string | null
16
- // Defaults to 'pat' when omitted. Only 'app' promotes an opened PR to a
17
- // review request; see classifyOpenedAsReview for why.
17
+ // Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
18
+ // matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
18
19
  authType?: () => 'pat' | 'app'
19
20
  route: (message: InboundMessage) => void
20
21
  logger: GithubInboundLogger
@@ -109,6 +110,7 @@ export function classifyGithubInbound(
109
110
  user,
110
111
  selfLogin,
111
112
  comment.created_at,
113
+ { kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
112
114
  )
113
115
  }
114
116
 
@@ -127,6 +129,7 @@ export function classifyGithubInbound(
127
129
  readUser(comment.user),
128
130
  selfLogin,
129
131
  comment.created_at,
132
+ { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
130
133
  )
131
134
  }
132
135
 
@@ -144,6 +147,7 @@ export function classifyGithubInbound(
144
147
  readUser(comment.user),
145
148
  selfLogin,
146
149
  comment.created_at,
150
+ null,
147
151
  )
148
152
  }
149
153
 
@@ -160,6 +164,7 @@ export function classifyGithubInbound(
160
164
  readUser(issue.user),
161
165
  selfLogin,
162
166
  issue.created_at,
167
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
163
168
  )
164
169
  }
165
170
 
@@ -178,17 +183,10 @@ export function classifyGithubInbound(
178
183
  number,
179
184
  base,
180
185
  selfLogin,
186
+ authType: options?.authType ?? 'pat',
181
187
  teamIsBotMember: options?.teamIsBotMember,
182
188
  })
183
189
  }
184
- // A GitHub App cannot be added to a PR's requested_reviewers, so it never
185
- // receives a review_requested event targeting itself. The opened event is
186
- // the only signal it can act on, so in App mode an opened PR is promoted to
187
- // a review request. A PAT-backed bot is a real user that can be requested,
188
- // so it waits for the explicit request instead of reviewing every PR.
189
- if (action === 'opened' && options?.authType === 'app') {
190
- return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
191
- }
192
190
  return buildInbound(
193
191
  { ...base, chat: `pr:${number}`, thread: null },
194
192
  pr.body,
@@ -196,6 +194,7 @@ export function classifyGithubInbound(
196
194
  readUser(pr.user),
197
195
  selfLogin,
198
196
  pr.created_at,
197
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
199
198
  )
200
199
  }
201
200
 
@@ -213,6 +212,7 @@ export function classifyGithubInbound(
213
212
  readUser(review.user),
214
213
  selfLogin,
215
214
  review.submitted_at,
215
+ null,
216
216
  )
217
217
  }
218
218
 
@@ -229,6 +229,7 @@ export function classifyGithubInbound(
229
229
  readUser(discussion.user),
230
230
  selfLogin,
231
231
  discussion.created_at,
232
+ null,
232
233
  )
233
234
  }
234
235
 
@@ -242,23 +243,46 @@ type ReviewRequestInput = {
242
243
  number: number
243
244
  base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
244
245
  selfLogin: string | null
246
+ authType: 'pat' | 'app'
245
247
  teamIsBotMember: boolean | undefined
246
248
  }
247
249
 
250
+ // A GitHub App can never be a `requested_reviewer` — that field only holds
251
+ // real user accounts, and the App actor (`slug[bot]`) is not one. The
252
+ // supported workaround is a decoy user account named after the App that an
253
+ // operator requests instead (see docs/content/docs/internals/github-decoy-reviewer.mdx).
254
+ // Its login is, by convention, the App slug — i.e. `selfLogin` with the
255
+ // `[bot]` suffix removed (`my-app[bot]` → `my-app`). This is the single seam
256
+ // where that login is resolved: when the decoy account's real login diverges
257
+ // from the slug, a future config field replaces this derivation without
258
+ // touching the matcher. PAT auth has no decoy (the bot IS a real user that can
259
+ // be requested directly), so it returns null.
260
+ const BOT_LOGIN_SUFFIX = '[bot]'
261
+
262
+ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
263
+ if (authType !== 'app') return null
264
+ if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
265
+ const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
266
+ return slug !== '' ? slug : null
267
+ }
268
+
248
269
  function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
249
- const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
270
+ const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
250
271
  if (selfLogin === null) return null
272
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
251
273
  const sender = readUser(payload.sender)
252
274
  if (sender === null) return null
253
- // Self-loop guard: if the bot itself requested (or un-requested) the
275
+ // Self-loop guard: if the bot (or its decoy) requested/un-requested the
254
276
  // review, drop the event. The bot adding itself as a reviewer would
255
277
  // otherwise wake a fresh session every time it self-assigns.
256
- if (sender.login === selfLogin) return null
278
+ if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
257
279
 
258
280
  const requestedUser = readUser(payload.requested_reviewer)
259
281
  const requestedTeam = readReviewerTeam(payload.requested_team)
260
282
 
261
- const isMeAsUser = requestedUser !== null && requestedUser.login === selfLogin
283
+ const isMeAsUser =
284
+ requestedUser !== null &&
285
+ (requestedUser.login === selfLogin || (decoyLogin !== null && requestedUser.login === decoyLogin))
262
286
  const isMyTeam = requestedTeam !== null && teamIsBotMember === true
263
287
  if (!isMeAsUser && !isMyTeam) return null
264
288
 
@@ -303,47 +327,6 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
303
327
  }
304
328
  }
305
329
 
306
- type OpenedAsReviewInput = {
307
- payload: Record<string, unknown>
308
- pr: Record<string, unknown>
309
- number: number
310
- base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
311
- selfLogin: string | null
312
- }
313
-
314
- function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
315
- const { payload, pr, number, base, selfLogin } = input
316
- if (selfLogin === null) return null
317
- const sender = readUser(payload.sender)
318
- if (sender === null) return null
319
- if (sender.login === selfLogin) return null
320
-
321
- const title = readString(pr, 'title') ?? `#${number}`
322
- const head = readString(readRecord(pr.head), 'ref')
323
- const baseRef = readString(readRecord(pr.base), 'ref')
324
- const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
325
- const text =
326
- `@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
327
- ' Please review the changes line-by-line and post your feedback.'
328
-
329
- const updatedAt = readString(pr, 'updated_at') ?? ''
330
- const prId = readNumber(pr, 'id') ?? number
331
-
332
- return {
333
- ...base,
334
- chat: `pr:${number}`,
335
- thread: null,
336
- text,
337
- externalMessageId: `pr-${prId}-opened-${updatedAt}`,
338
- authorId: String(sender.id),
339
- authorName: sender.login,
340
- authorIsBot: sender.type === 'Bot',
341
- isBotMention: true,
342
- replyToBotMessageId: null,
343
- ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
344
- }
345
- }
346
-
347
330
  export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
348
331
 
349
332
  export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
@@ -365,6 +348,7 @@ function buildInbound(
365
348
  user: GithubUser | null,
366
349
  selfLogin: string | null,
367
350
  rawTs: unknown,
351
+ reactionTarget: GithubReactionTarget | null,
368
352
  ): InboundMessage | null {
369
353
  if (user === null) return null
370
354
  const text = typeof rawText === 'string' ? rawText : ''
@@ -372,6 +356,7 @@ function buildInbound(
372
356
  ...key,
373
357
  text,
374
358
  externalMessageId: String(id),
359
+ ...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
375
360
  authorId: String(user.id),
376
361
  authorName: user.login,
377
362
  authorIsBot: user.type === 'Bot',
@@ -1,3 +1,4 @@
1
+ import type { GithubTokenBridge } from '@/channels/github-token-bridge'
1
2
  import type { ChannelRouter } from '@/channels/router'
2
3
  import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
3
4
  import { resolveSecret } from '@/secrets/resolve'
@@ -18,6 +19,7 @@ import {
18
19
  buildPermissionGuidance,
19
20
  parseListHooksPermissionStatus,
20
21
  } from './permission-guidance'
22
+ import { createGithubReactionCallback } from './reactions'
21
23
  import { createTeamMembershipChecker } from './team-membership'
22
24
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
23
25
 
@@ -61,6 +63,12 @@ export type GithubAdapterOptions = {
61
63
  // Test-only: replaces `setInterval` so tests can control when the
62
64
  // background refresh fires without waiting on real wall-clock time.
63
65
  setInterval?: (handler: () => void, ms: number) => { clear: () => void }
66
+ // Write-side of the GithubTokenBridge. On App-auth start the adapter
67
+ // registers a per-repo minter here so plugin hooks can resolve a token for
68
+ // ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
69
+ // auth does not register (the seeded GH_TOKEN already covers every repo a
70
+ // classic PAT can reach, and a fine-grained PAT cannot be re-minted per repo).
71
+ githubTokenBridge?: GithubTokenBridge
64
72
  }
65
73
 
66
74
  export type GithubAdapter = {
@@ -93,6 +101,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
93
101
  let started = false
94
102
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
95
103
  let tokenRefreshTimer: { clear: () => void } | null = null
104
+ let unregisterTokenBridge: (() => void) | null = null
96
105
  const workspaceByChat = new Map<string, string>()
97
106
 
98
107
  const rememberWorkspace = (workspace: string, chat: string): void => {
@@ -111,6 +120,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
111
120
  logger,
112
121
  fetchImpl,
113
122
  })
123
+ const reaction = createGithubReactionCallback({
124
+ token: authToken,
125
+ authType: options.secrets.auth.type,
126
+ fetchImpl,
127
+ })
114
128
  const history = createGithubHistoryCallback({
115
129
  token: authToken,
116
130
  fetchImpl,
@@ -153,6 +167,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
153
167
  // Register all callbacks before binding the HTTP listener so the router
154
168
  // is fully wired before any webhook can arrive.
155
169
  options.router.registerOutbound('github', outbound)
170
+ options.router.registerReaction('github', reaction)
156
171
  options.router.registerTyping('github', typing)
157
172
  options.router.registerHistory('github', history)
158
173
  options.router.registerMembership('github', membership)
@@ -164,6 +179,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
164
179
  // Listener failed — roll back all registrations so stop() is a no-op
165
180
  // and the manager can report the failure cleanly.
166
181
  options.router.unregisterOutbound('github', outbound)
182
+ options.router.unregisterReaction('github', reaction)
167
183
  options.router.unregisterTyping('github', typing)
168
184
  options.router.unregisterHistory('github', history)
169
185
  options.router.unregisterMembership('github', membership)
@@ -176,17 +192,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
176
192
  throw err
177
193
  }
178
194
  started = true
179
- // GH_TOKEN is a single process-wide env var the container's `gh` CLI
180
- // reads, but a GitHub App spanning multiple owners has no single correct
181
- // token. Seed/refresh it only when exactly one repo is configured (PAT,
182
- // or App with one unambiguous installation). With multiple repos we skip
183
- // the global seed: ad-hoc `gh` calls must target a specific repo, and the
184
- // adapter's own API calls always resolve a repo-scoped token via authToken.
185
- const ghTokenRepo = ghTokenSeedRepo(options.configRef().repos ?? [])
186
- const seedGhToken = async (): Promise<void> => {
187
- process.env.GH_TOKEN = await auth.token(ghTokenRepo === null ? undefined : { repoSlug: ghTokenRepo })
188
- }
189
- if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
195
+ // Seed the process-wide GH_TOKEN when it's unambiguous; skip otherwise.
196
+ // See ghTokenSeedDecision for why one owner is required. On skip, authToken
197
+ // still resolves a repo-scoped token per call for the adapter's own traffic.
198
+ const seed = ghTokenSeedDecision(options.secrets.auth.type, options.configRef().repos ?? [])
199
+ if (seed.kind === 'seed') {
200
+ const seedContext = seed.context
201
+ const seedGhToken = async (): Promise<void> => {
202
+ process.env.GH_TOKEN = await auth.token(seedContext)
203
+ }
190
204
  await seedGhToken()
191
205
  const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
192
206
  if (tokenRefreshIntervalMs > 0) {
@@ -207,10 +221,28 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
207
221
  }
208
222
  } else {
209
223
  logger.info(
210
- '[github] multiple repos configured across possibly-different owners; GH_TOKEN not seeded globally. ' +
211
- 'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
224
+ `${GH_TOKEN_SKIP_LOG[seed.reason]} Ad-hoc \`gh\` commands should set a repo-scoped token explicitly.`,
212
225
  )
213
226
  }
227
+ if (options.secrets.auth.type === 'app' && options.githubTokenBridge !== undefined) {
228
+ // Gate ad-hoc `gh` minting on the configured repos[]. The slug arrives
229
+ // from an attacker-controllable -R/--repo flag (untrusted PR/issue
230
+ // content can prompt-inject it); without this an injected `-R any/repo`
231
+ // would mint an installation-wide token for any repo the App is installed
232
+ // on — a cross-tenant leak under a multi-owner App. Enforced here, not in
233
+ // the parser, because this adapter is the authority that owns repos[].
234
+ unregisterTokenBridge = options.githubTokenBridge.registerResolver((repoSlug) => {
235
+ const allowed = new Set((options.configRef().repos ?? []).map(canonicalRepoSlug))
236
+ if (!allowed.has(canonicalRepoSlug(repoSlug))) {
237
+ throw new Error(
238
+ `repo \`${repoSlug}\` is not in this agent's configured \`channels.github.repos[]\`; ` +
239
+ 'refusing to mint a GitHub App token for it. Target a configured repo, ' +
240
+ 'or add it to `repos[]` if the agent is meant to operate there.',
241
+ )
242
+ }
243
+ return auth.token({ repoSlug })
244
+ })
245
+ }
214
246
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
215
247
  // Best-effort: App-only preflight that compares the installation's granted
216
248
  // permissions against the configured eventAllowlist and warns about gaps.
@@ -268,6 +300,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
268
300
  if (!started) return
269
301
  started = false
270
302
  options.router.unregisterOutbound('github', outbound)
303
+ options.router.unregisterReaction('github', reaction)
271
304
  options.router.unregisterTyping('github', typing)
272
305
  options.router.unregisterHistory('github', history)
273
306
  options.router.unregisterMembership('github', membership)
@@ -291,6 +324,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
291
324
  tokenRefreshTimer.clear()
292
325
  tokenRefreshTimer = null
293
326
  }
327
+ if (unregisterTokenBridge !== null) {
328
+ unregisterTokenBridge()
329
+ unregisterTokenBridge = null
330
+ }
294
331
  await auth.dispose()
295
332
  delete process.env.GH_TOKEN
296
333
  server = null
@@ -427,11 +464,45 @@ function logDeregistrationOutcome(
427
464
  }
428
465
  }
429
466
 
430
- // Two repos under the same owner share an installation and could in principle
431
- // share a global GH_TOKEN, but the marginal value doesn't justify the special
432
- // case only a single configured repo yields an unambiguous seed.
433
- function ghTokenSeedRepo(repos: readonly string[]): string | null {
434
- return repos.length === 1 ? (repos[0] ?? null) : null
467
+ type GhTokenSeedDecision =
468
+ | { kind: 'seed'; context?: GithubAuthContext }
469
+ | { kind: 'skip'; reason: 'no-repos' | 'multiple-owners' }
470
+
471
+ const GH_TOKEN_SKIP_LOG: Record<'no-repos' | 'multiple-owners', string> = {
472
+ 'no-repos':
473
+ '[github] no repos[] configured; GH_TOKEN not seeded globally (cannot prove which App installation to use).',
474
+ 'multiple-owners': '[github] repos span multiple owners (multiple App installations); GH_TOKEN not seeded globally.',
475
+ }
476
+
477
+ // Decides how to seed the process-wide GH_TOKEN. PATs aren't installation-scoped
478
+ // (seed context-free). For App auth we seed from a configured repo slug, which
479
+ // resolves the installation via repos/{owner}/{repo}/installation — the only
480
+ // lookup that works for both org- and user-owned repos. One owner is required:
481
+ // no-repos can't prove an installation, multi-owner needs >1 token.
482
+ function ghTokenSeedDecision(authType: 'pat' | 'app', repos: readonly string[]): GhTokenSeedDecision {
483
+ if (authType === 'pat') return { kind: 'seed' }
484
+ const slugs = [...new Set(repos.filter(isWellFormedSlug))].sort()
485
+ if (slugs.length === 0) return { kind: 'skip', reason: 'no-repos' }
486
+ const owners = new Set(slugs.map((slug) => slug.split('/')[0]))
487
+ if (owners.size > 1) return { kind: 'skip', reason: 'multiple-owners' }
488
+ return { kind: 'seed', context: { repoSlug: slugs[0] } }
489
+ }
490
+
491
+ function isWellFormedSlug(repo: string): boolean {
492
+ const [owner, name, ...rest] = repo.split('/')
493
+ return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
494
+ }
495
+
496
+ // Canonical form for repos[] allowlist comparison so the gate can't be bypassed
497
+ // by case, a trailing slash, or a `.git` suffix (GitHub treats owner/name
498
+ // case-insensitively). Applied identically to both configured repos[] and the
499
+ // runtime slug before exact Set membership.
500
+ function canonicalRepoSlug(repo: string): string {
501
+ return repo
502
+ .trim()
503
+ .replace(/\/+$/, '')
504
+ .replace(/\.git$/i, '')
505
+ .toLowerCase()
435
506
  }
436
507
 
437
508
  function defaultSleep(ms: number): Promise<void> {
@@ -28,6 +28,10 @@ export function createGithubMembershipResolver(options: {
28
28
  if (user.type === 'Bot') bots++
29
29
  else humans++
30
30
  }
31
+ // Counts only, no humanMemberIds: GitHub membership is the repo
32
+ // collaborator list, a different population from the authors that can
33
+ // comment into a PR/issue turn, so it is not a basis for the channel
34
+ // grant-role relaxation (see provesOnlyAgentBotPresent in grant-role.ts).
31
35
  return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
32
36
  } catch {
33
37
  return { kind: 'transient' }