typeclaw 0.9.2 → 0.11.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 (76) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +46 -11
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/index.ts +19 -17
  14. package/src/bundled-plugins/security/permissions.ts +9 -8
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  16. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  17. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  18. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  19. package/src/channels/adapters/github/auth-app.ts +53 -9
  20. package/src/channels/adapters/github/auth-pat.ts +4 -1
  21. package/src/channels/adapters/github/auth.ts +10 -0
  22. package/src/channels/adapters/github/event-permissions.ts +83 -0
  23. package/src/channels/adapters/github/inbound.ts +126 -1
  24. package/src/channels/adapters/github/index.ts +60 -66
  25. package/src/channels/adapters/github/outbound.ts +65 -17
  26. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  27. package/src/channels/adapters/github/team-membership.ts +56 -0
  28. package/src/channels/router.ts +313 -10
  29. package/src/channels/schema.ts +22 -0
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +135 -38
  32. package/src/cli/cron.ts +1 -1
  33. package/src/cli/init.ts +133 -86
  34. package/src/cli/inspect-controller.ts +66 -0
  35. package/src/cli/inspect.ts +99 -14
  36. package/src/cli/role.ts +2 -2
  37. package/src/cli/run.ts +24 -5
  38. package/src/cli/tui.ts +34 -10
  39. package/src/cli/tunnel.ts +453 -14
  40. package/src/config/config.ts +35 -7
  41. package/src/config/providers.ts +82 -56
  42. package/src/cron/bridge.ts +25 -4
  43. package/src/hostd/daemon.ts +44 -24
  44. package/src/hostd/portbroker-manager.ts +19 -3
  45. package/src/init/dockerfile.ts +52 -0
  46. package/src/init/env-file.ts +66 -0
  47. package/src/init/gitignore.ts +8 -0
  48. package/src/init/hatching.ts +32 -5
  49. package/src/init/index.ts +131 -39
  50. package/src/init/validate-api-key.ts +31 -0
  51. package/src/inspect/index.ts +47 -6
  52. package/src/inspect/loop.ts +31 -0
  53. package/src/inspect/replay.ts +15 -1
  54. package/src/permissions/builtins.ts +29 -21
  55. package/src/permissions/permissions.ts +32 -5
  56. package/src/role-claim/code.ts +9 -9
  57. package/src/role-claim/controller.ts +3 -2
  58. package/src/role-claim/match-rule.ts +14 -19
  59. package/src/role-claim/pending.ts +2 -2
  60. package/src/run/codex-fetch-observer.ts +377 -0
  61. package/src/run/index.ts +12 -2
  62. package/src/server/index.ts +59 -1
  63. package/src/shared/protocol.ts +1 -1
  64. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  65. package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
  66. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
  67. package/src/skills/typeclaw-config/SKILL.md +7 -1
  68. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  69. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  70. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  71. package/src/tui/index.ts +17 -5
  72. package/src/tunnels/index.ts +1 -0
  73. package/src/tunnels/manager.ts +18 -0
  74. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  75. package/src/tunnels/types.ts +17 -1
  76. package/typeclaw.schema.json +120 -7
@@ -8,25 +8,32 @@ import type { SecuritySeverity } from '../permissions'
8
8
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
9
9
 
10
10
  export const GUARD_ROLE_PROMOTION = 'rolePromotion'
11
- // Classified `high` (audience-leak axis, adapted).
11
+ // Classified `medium` (silent-attack axis). Originally `high`; reclassified
12
+ // because the privilege escalation does NOT take effect until the operator
13
+ // reloads or restarts — `roles` is `restart-required` in FIELD_EFFECTS, and
14
+ // even the `match`-only path that's classified `applied` writes through
15
+ // `typeclaw.json` which is force-committed by the auto-backup plugin on
16
+ // idle. The operator sees the file change in `git log`, in `typeclaw reload`
17
+ // output, and in their backup commits BEFORE the new role mapping takes
18
+ // effect. There is an operator-visible step between bypass and breach,
19
+ // which puts this guard squarely on the medium axis: bypass produces
20
+ // attacker-favorable state in operator-reviewable surface, not direct
21
+ // audience-leak.
12
22
  //
13
- // Role promotion is privilege escalation: the agent rewrites
14
- // `typeclaw.json#roles` so a previously-unprivileged actor now resolves
15
- // to a privileged role. The breach pattern: a `member`-role speaker in a
16
- // chat asks "give me permission" / "promote me to admin"; the agent
17
- // edits typeclaw.json with what looks like a routine config change; the
18
- // schema is valid, the managedConfig guard passes, nonWorkspaceWrite
19
- // allowlists typeclaw.json and on next reload the speaker resolves to
20
- // `owner` with full bypasses.
23
+ // Net effect on the role-tower model: owner and trusted both bypass without
24
+ // ack; member and guest still get blocked. The defense for trusted now
25
+ // depends on operator config-review discipline if backup commits are
26
+ // reviewed and `typeclaw reload` output is read before applying, a
27
+ // trusted-laundered role promotion is caught before it fires. Operators
28
+ // who do not review can re-tighten by adding `security.bypass.rolePromotion`
29
+ // to `trusted.permissions[]` as an explicit subtraction (replace the
30
+ // default tier grant with a narrower list) — see typeclaw-permissions skill.
21
31
  //
22
- // This is the same audience-leak shape as gitExfil and outboundSecret:
23
- // the "audience" here is the future-self of the access-control table,
24
- // which is outside the operator's per-call control loop. Even an `owner`
25
- // operating from TUI must not silently rewrite the role table based on
26
- // a channel message — the canonical owner-in-public-channel attack
27
- // generalizes from "post credentials" to "promote the asker". No role
28
- // auto-bypasses; per-call ack or an explicit `security.bypass.rolePromotion`
29
- // grant is required.
32
+ // Breach pattern blocked at `medium`: a `member`-role speaker in a chat
33
+ // asks "promote me to admin"; the agent edits typeclaw.json; the change is
34
+ // schema-valid, managedConfig accepts it, nonWorkspaceWrite allowlists
35
+ // typeclaw.json but this guard still blocks because member does not
36
+ // carry `bypass.medium` by default.
30
37
  //
31
38
  // What counts as a promotion (any of):
32
39
  // 1. A role's `permissions[]` gained an entry.
@@ -58,7 +65,7 @@ export const GUARD_ROLE_PROMOTION = 'rolePromotion'
58
65
  // agent cannot start a claim, only consume one whose code the
59
66
  // operator already broadcast. That makes the bypass intentionally
60
67
  // out-of-band — do not extend this guard to cover it.
61
- export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = 'high'
68
+ export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = 'medium'
62
69
 
63
70
  export type RolePromotionFinding = {
64
71
  role: string
@@ -1,7 +1,9 @@
1
+ import { createPrivateKey } from 'node:crypto'
2
+
1
3
  import { resolveSecret, type Secret } from '@/secrets/resolve'
2
4
 
3
- import type { GithubAuthStrategy, GithubSelfUser } from './auth'
4
- import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+ import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
6
+ import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
5
7
 
6
8
  export class AppAuthStrategy implements GithubAuthStrategy {
7
9
  private readonly appId: number
@@ -52,8 +54,11 @@ export class AppAuthStrategy implements GithubAuthStrategy {
52
54
  if (typeof app.slug !== 'string') throw new Error('GitHub /app response missing slug')
53
55
 
54
56
  const botLogin = `${app.slug}[bot]`
57
+ // GET /users/{login} is a public endpoint and rejects App JWTs with 401.
58
+ // Installation tokens also fail here (404 — they're scoped to repos, not user lookups).
59
+ // The bot user is publicly visible, so no auth is the only path that works.
55
60
  const userResponse = await this.fetchImpl(`${GITHUB_API_BASE}/users/${encodeURIComponent(botLogin)}`, {
56
- headers: githubJsonHeaders(jwt),
61
+ headers: githubPublicHeaders(),
57
62
  })
58
63
  if (!userResponse.ok) throw new Error(`GitHub bot user lookup failed: ${userResponse.status}`)
59
64
  const user = (await userResponse.json()) as { id?: unknown; login?: unknown }
@@ -64,6 +69,24 @@ export class AppAuthStrategy implements GithubAuthStrategy {
64
69
  return this._selfUser
65
70
  }
66
71
 
72
+ async getInstallationGrants(): Promise<GithubInstallationGrants> {
73
+ const jwt = await this.mintJwt()
74
+ const installId = await this.resolveInstallationId(jwt)
75
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
76
+ headers: githubJsonHeaders(jwt),
77
+ })
78
+ if (!response.ok) throw new Error(`GitHub App installation fetch failed: ${response.status}`)
79
+ const raw = (await response.json()) as { permissions?: unknown; events?: unknown }
80
+ const permissions: Record<string, 'read' | 'write' | 'admin'> = {}
81
+ if (raw.permissions !== null && typeof raw.permissions === 'object') {
82
+ for (const [key, value] of Object.entries(raw.permissions as Record<string, unknown>)) {
83
+ if (value === 'read' || value === 'write' || value === 'admin') permissions[key] = value
84
+ }
85
+ }
86
+ const events = Array.isArray(raw.events) ? raw.events.filter((e): e is string => typeof e === 'string') : []
87
+ return { permissions, events }
88
+ }
89
+
67
90
  async dispose(): Promise<void> {
68
91
  this.cachedToken = null
69
92
  }
@@ -111,10 +134,31 @@ function base64url(input: string | Buffer): string {
111
134
  }
112
135
 
113
136
  async function importRsaPrivateKey(pem: string): Promise<CryptoKey> {
114
- const b64 = pem
115
- .replace(/-----BEGIN [^-]+-----/, '')
116
- .replace(/-----END [^-]+-----/, '')
117
- .replace(/\s/g, '')
118
- const der = Buffer.from(b64, 'base64')
119
- return await crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
137
+ // GitHub's "Generate a private key" button hands out PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`),
138
+ // but WebCrypto's importKey only accepts PKCS#8. Round-trip through node:crypto, which accepts
139
+ // both PKCS#1 and PKCS#8 PEM, then re-export as PKCS#8 DER for WebCrypto.
140
+ const pkcs8Der = pemToPkcs8Der(pem)
141
+ return await crypto.subtle.importKey('pkcs8', pkcs8Der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, [
142
+ 'sign',
143
+ ])
144
+ }
145
+
146
+ function pemToPkcs8Der(pem: string): ArrayBuffer {
147
+ if (/-----BEGIN ENCRYPTED PRIVATE KEY-----/.test(pem)) {
148
+ throw new Error('GitHub App private key is encrypted; provide an unencrypted PEM')
149
+ }
150
+ let keyObject
151
+ try {
152
+ keyObject = createPrivateKey({ key: pem, format: 'pem' })
153
+ } catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error)
155
+ throw new Error(`GitHub App private key is invalid: ${message}`)
156
+ }
157
+ if (keyObject.asymmetricKeyType !== 'rsa') {
158
+ throw new Error(`GitHub App private key must be RSA, got ${keyObject.asymmetricKeyType ?? 'unknown'}`)
159
+ }
160
+ const der = keyObject.export({ type: 'pkcs8', format: 'der' })
161
+ const out = new ArrayBuffer(der.byteLength)
162
+ new Uint8Array(out).set(der)
163
+ return out
120
164
  }
@@ -40,8 +40,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
40
40
  }
41
41
 
42
42
  export function githubJsonHeaders(token: string): HeadersInit {
43
+ return { ...githubPublicHeaders(), Authorization: `Bearer ${token}` }
44
+ }
45
+
46
+ export function githubPublicHeaders(): HeadersInit {
43
47
  return {
44
- Authorization: `Bearer ${token}`,
45
48
  Accept: 'application/vnd.github+json',
46
49
  'Content-Type': 'application/json',
47
50
  'X-GitHub-Api-Version': '2022-11-28',
@@ -7,9 +7,19 @@ export type GithubAuthStrategy = {
7
7
  token: () => Promise<string>
8
8
  authHeaders: () => Promise<HeadersInit>
9
9
  getSelf: () => Promise<GithubSelfUser>
10
+ // App-only: returns the installation's granted-permissions map and declared
11
+ // events so the adapter can preflight against the configured eventAllowlist
12
+ // before any webhook arrives. PATs return access via token scopes, not an
13
+ // installation grant, so they leave this undefined.
14
+ getInstallationGrants?: () => Promise<GithubInstallationGrants>
10
15
  dispose: () => Promise<void>
11
16
  }
12
17
 
18
+ export type GithubInstallationGrants = {
19
+ permissions: Readonly<Record<string, 'read' | 'write' | 'admin'>>
20
+ events: readonly string[]
21
+ }
22
+
13
23
  export type GithubSelfUser = {
14
24
  login: string
15
25
  id: number
@@ -0,0 +1,83 @@
1
+ // Maps a GitHub webhook event (in the form used in typeclaw.json#channels.github.eventAllowlist,
2
+ // e.g. "issue_comment.created" or just "issues") to the GitHub App "Repository permissions"
3
+ // key that gates BOTH receiving payload fields AND posting replies for that event family.
4
+ //
5
+ // Source: https://docs.github.com/en/webhooks/webhook-events-and-payloads (each event page
6
+ // links to the App permission it requires).
7
+ //
8
+ // The permission key on the LEFT is what github.com calls the permission in the App settings UI
9
+ // ("Issues", "Pull requests", "Discussions"); the value on the RIGHT is the snake_case key that
10
+ // appears in the `permissions` object on GET /app/installations/{id} responses. They MUST match
11
+ // the strings GitHub actually emits — these are checked at runtime against an installation grant
12
+ // map, not normalised.
13
+ export const EVENT_PERMISSION_KEY: Record<string, string> = {
14
+ issues: 'issues',
15
+ issue_comment: 'issues',
16
+ pull_request: 'pull_requests',
17
+ pull_request_review: 'pull_requests',
18
+ pull_request_review_comment: 'pull_requests',
19
+ pull_request_review_thread: 'pull_requests',
20
+ discussion: 'discussions',
21
+ discussion_comment: 'discussions',
22
+ commit_comment: 'contents',
23
+ push: 'contents',
24
+ }
25
+
26
+ // Human-readable label for each App permission key, mirroring github.com's
27
+ // "Repository permissions" section verbatim. Used in the preflight warning so
28
+ // users can grep for the exact string on the App settings page.
29
+ export const PERMISSION_UI_LABEL: Record<string, string> = {
30
+ issues: 'Issues',
31
+ pull_requests: 'Pull requests',
32
+ discussions: 'Discussions',
33
+ contents: 'Contents',
34
+ metadata: 'Metadata',
35
+ }
36
+
37
+ export type GrantLevel = 'read' | 'write' | 'admin'
38
+
39
+ // Accepts both the dotted form ("issues.opened", as used in
40
+ // typeclaw.json#channels.github.eventAllowlist) and the bare event family
41
+ // ("issues", as used in webhook event-header names).
42
+ export function permissionKeyForEvent(event: string): string | null {
43
+ const family = event.includes('.') ? event.slice(0, event.indexOf('.')) : event
44
+ return EVENT_PERMISSION_KEY[family] ?? null
45
+ }
46
+
47
+ export type PermissionGap = {
48
+ permissionKey: string
49
+ uiLabel: string
50
+ granted: GrantLevel | null
51
+ events: string[]
52
+ needsWrite: boolean
53
+ }
54
+
55
+ // Unknown allowlist items are silently ignored — forward-compat for events
56
+ // typeclaw doesn't yet know about. `needsWrite` is hardcoded true because
57
+ // channel_reply is today's only canonical exit; flip to a per-event flag the
58
+ // day a read-only github channel becomes a supported use case.
59
+ export function findPermissionGaps(
60
+ eventAllowlist: readonly string[],
61
+ installationPermissions: Readonly<Record<string, GrantLevel>>,
62
+ ): PermissionGap[] {
63
+ const eventsByKey = new Map<string, Set<string>>()
64
+ for (const event of eventAllowlist) {
65
+ const key = permissionKeyForEvent(event)
66
+ if (key === null) continue
67
+ if (!eventsByKey.has(key)) eventsByKey.set(key, new Set())
68
+ eventsByKey.get(key)?.add(event)
69
+ }
70
+ const gaps: PermissionGap[] = []
71
+ for (const [permissionKey, events] of eventsByKey) {
72
+ const granted = installationPermissions[permissionKey] ?? null
73
+ if (granted === 'write' || granted === 'admin') continue
74
+ gaps.push({
75
+ permissionKey,
76
+ uiLabel: PERMISSION_UI_LABEL[permissionKey] ?? permissionKey,
77
+ granted,
78
+ events: [...events].sort(),
79
+ needsWrite: true,
80
+ })
81
+ }
82
+ return gaps.sort((a, b) => a.permissionKey.localeCompare(b.permissionKey))
83
+ }
@@ -15,6 +15,10 @@ export type GithubWebhookHandlerOptions = {
15
15
  selfLogin: () => string | null
16
16
  route: (message: InboundMessage) => void
17
17
  logger: GithubInboundLogger
18
+ // Optional: resolves whether the bot is a member of the given team. When
19
+ // omitted, team-reviewer requests are silently dropped (the v1 fallback
20
+ // behavior). The adapter wires this in production; tests inject a fake.
21
+ isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
18
22
  }
19
23
 
20
24
  export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
@@ -43,7 +47,10 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
43
47
  const author = readAuthor(payload)
44
48
  if (selfId !== null && author !== null && String(author.id) === selfId) return ok()
45
49
 
46
- const classified = classifyGithubInbound(event, payload, options.selfLogin())
50
+ const teamIsBotMember = await resolveTeamMembership(event, payload, options)
51
+ const classified = classifyGithubInbound(event, payload, options.selfLogin(), {
52
+ teamIsBotMember,
53
+ })
47
54
  if (classified === null) return ok()
48
55
 
49
56
  if (delivery !== '') options.dedup.add(delivery)
@@ -64,6 +71,7 @@ export function classifyGithubInbound(
64
71
  event: string,
65
72
  payload: Record<string, unknown>,
66
73
  selfLogin: string | null,
74
+ options?: { teamIsBotMember?: boolean },
67
75
  ): InboundMessage | null {
68
76
  const repository = readRepository(payload)
69
77
  if (repository === null) return null
@@ -151,6 +159,18 @@ export function classifyGithubInbound(
151
159
  const number = readNumber(pr, 'number')
152
160
  const id = readNumber(pr, 'id') ?? number
153
161
  if (number === null || id === null) return null
162
+ const action = readString(payload, 'action')
163
+ if (action === 'review_requested' || action === 'review_request_removed') {
164
+ return classifyReviewRequest({
165
+ action,
166
+ payload,
167
+ pr,
168
+ number,
169
+ base,
170
+ selfLogin,
171
+ teamIsBotMember: options?.teamIsBotMember,
172
+ })
173
+ }
154
174
  return buildInbound(
155
175
  { ...base, chat: `pr:${number}`, thread: null },
156
176
  pr.body,
@@ -197,6 +217,85 @@ export function classifyGithubInbound(
197
217
  return null
198
218
  }
199
219
 
220
+ type ReviewRequestInput = {
221
+ action: 'review_requested' | 'review_request_removed'
222
+ payload: Record<string, unknown>
223
+ pr: Record<string, unknown>
224
+ number: number
225
+ base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
226
+ selfLogin: string | null
227
+ teamIsBotMember: boolean | undefined
228
+ }
229
+
230
+ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
231
+ const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
232
+ if (selfLogin === null) return null
233
+ const sender = readUser(payload.sender)
234
+ if (sender === null) return null
235
+ // Self-loop guard: if the bot itself requested (or un-requested) the
236
+ // review, drop the event. The bot adding itself as a reviewer would
237
+ // otherwise wake a fresh session every time it self-assigns.
238
+ if (sender.login === selfLogin) return null
239
+
240
+ const requestedUser = readUser(payload.requested_reviewer)
241
+ const requestedTeam = readReviewerTeam(payload.requested_team)
242
+
243
+ const isMeAsUser = requestedUser !== null && requestedUser.login === selfLogin
244
+ const isMyTeam = requestedTeam !== null && teamIsBotMember === true
245
+ if (!isMeAsUser && !isMyTeam) return null
246
+
247
+ const title = readString(pr, 'title') ?? `#${number}`
248
+ const head = readString(readRecord(pr.head), 'ref')
249
+ const baseRef = readString(readRecord(pr.base), 'ref')
250
+ const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
251
+ const verbed =
252
+ action === 'review_requested'
253
+ ? isMyTeam
254
+ ? `requested a review from team @${requestedTeam?.slug} (you're a member of) on PR #${number}: "${title}".`
255
+ : `requested your review on PR #${number}: "${title}".`
256
+ : isMyTeam
257
+ ? `removed the review request for team @${requestedTeam?.slug} on PR #${number}: "${title}".`
258
+ : `removed your review request on PR #${number}: "${title}".`
259
+ const closing =
260
+ action === 'review_requested'
261
+ ? ' Please review the changes line-by-line and post your feedback.'
262
+ : ' You can stop any in-progress review.'
263
+ const text = `@${sender.login} ${verbed}${branchSegment}${closing}`
264
+
265
+ // Synthesize a stable per-event externalMessageId. The PR's `updated_at`
266
+ // changes on every review-request mutation, so combining it with the PR id
267
+ // and the action keeps separate "requested → removed → requested again"
268
+ // events from collapsing into one dedup'd id.
269
+ const updatedAt = readString(pr, 'updated_at') ?? ''
270
+ const prId = readNumber(pr, 'id') ?? number
271
+ const externalMessageId = `pr-${prId}-${action}-${updatedAt}`
272
+
273
+ return {
274
+ ...base,
275
+ chat: `pr:${number}`,
276
+ thread: null,
277
+ text,
278
+ externalMessageId,
279
+ authorId: String(sender.id),
280
+ authorName: sender.login,
281
+ authorIsBot: sender.type === 'Bot',
282
+ isBotMention: true,
283
+ replyToBotMessageId: null,
284
+ ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
285
+ }
286
+ }
287
+
288
+ export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
289
+
290
+ export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
291
+ const team = readRecord(value)
292
+ const slug = readString(team, 'slug')
293
+ const id = readNumber(team, 'id')
294
+ if (slug === null || id === null) return null
295
+ const org = readString(readRecord(team?.organization), 'login')
296
+ return { slug, id, org }
297
+ }
298
+
200
299
  function buildInbound(
201
300
  key: Pick<
202
301
  InboundMessage,
@@ -223,6 +322,32 @@ function buildInbound(
223
322
  }
224
323
  }
225
324
 
325
+ async function resolveTeamMembership(
326
+ event: string,
327
+ payload: Record<string, unknown>,
328
+ options: GithubWebhookHandlerOptions,
329
+ ): Promise<boolean | undefined> {
330
+ if (event !== 'pull_request') return undefined
331
+ const action = readString(payload, 'action')
332
+ if (action !== 'review_requested' && action !== 'review_request_removed') return undefined
333
+ const team = readReviewerTeam(payload.requested_team)
334
+ if (team === null) return undefined
335
+ const selfLogin = options.selfLogin()
336
+ if (selfLogin === null) return false
337
+ if (options.isBotInTeam === undefined) return false
338
+ // The team payload sometimes omits `organization.login`. Fall back to the
339
+ // repository owner, which is the only org GitHub can legally route team
340
+ // reviewers from on a given PR.
341
+ const org = team.org ?? readRepository(payload)?.owner ?? null
342
+ if (org === null) return false
343
+ try {
344
+ return await options.isBotInTeam({ org, slug: team.slug, login: selfLogin })
345
+ } catch (err) {
346
+ options.logger.warn(`[github] team membership lookup failed: ${describe(err)}`)
347
+ return false
348
+ }
349
+ }
350
+
226
351
  function readRepository(payload: Record<string, unknown>): { owner: string; name: string } | null {
227
352
  const repository = readRecord(payload.repository)
228
353
  const owner = readRecord(repository?.owner)
@@ -6,12 +6,19 @@ import type { GithubSecretsBlock } from '@/secrets/schema'
6
6
  import { buildAuthStrategy } from './auth'
7
7
  import { createGithubChannelNameResolver } from './channel-resolver'
8
8
  import { createDeliveryDedup } from './dedup'
9
+ import { findPermissionGaps } from './event-permissions'
9
10
  import { createGithubFetchAttachmentCallback } from './fetch-attachment'
10
11
  import { createGithubHistoryCallback } from './history'
11
12
  import { createGithubWebhookHandler } from './inbound'
12
13
  import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
13
14
  import { createGithubMembershipResolver } from './membership'
14
15
  import { createGithubOutboundCallback } from './outbound'
16
+ import {
17
+ buildAppPermissionPreflightGuidance,
18
+ buildPermissionGuidance,
19
+ parseListHooksPermissionStatus,
20
+ } from './permission-guidance'
21
+ import { createTeamMembershipChecker } from './team-membership'
15
22
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
16
23
 
17
24
  export type GithubAdapterLogger = {
@@ -35,6 +42,17 @@ export type GithubAdapterOptions = {
35
42
  // wrong)" so the skip-registration log can be precise and actionable.
36
43
  // Optional so tests that don't exercise the tunnel-status path can omit it.
37
44
  tunnelConfiguredForChannel?: () => boolean
45
+ // Sleep between learning the public webhook URL and telling GitHub about
46
+ // it. cloudflared prints the trycloudflare.com URL as soon as the control
47
+ // connection comes up, but the Cloudflare edge needs a beat to start
48
+ // routing traffic for that hostname. If we register with GitHub the
49
+ // instant we know the URL, GitHub's automatic `ping` delivery races the
50
+ // edge and lands "failed to connect to host". 2s is enough on every
51
+ // network we've tested; tests pass 0 to skip.
52
+ webhookRegistrationDelayMs?: number
53
+ // Test-only: replaces the wall-clock sleep used for the registration
54
+ // delay above. Production leaves it undefined and we use `setTimeout`.
55
+ sleep?: (ms: number) => Promise<void>
38
56
  }
39
57
 
40
58
  export type GithubAdapter = {
@@ -49,9 +67,13 @@ const consoleLogger: GithubAdapterLogger = {
49
67
  error: (m) => console.error(m),
50
68
  }
51
69
 
70
+ const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
71
+
52
72
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
53
73
  const logger = options.logger ?? consoleLogger
54
74
  const fetchImpl = options.fetchImpl ?? fetch
75
+ const webhookRegistrationDelayMs = options.webhookRegistrationDelayMs ?? DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS
76
+ const sleep = options.sleep ?? defaultSleep
55
77
  const auth = buildAuthStrategy({ auth: options.secrets.auth, fetchImpl })
56
78
  const webhookSecret = resolveSecret(options.secrets.webhookSecret, undefined, process.env)
57
79
  if (webhookSecret === undefined || webhookSecret.trim() === '') throw new Error('GitHub webhookSecret is missing')
@@ -72,7 +94,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
72
94
  process.env.GH_TOKEN = t
73
95
  return t
74
96
  }
75
- const outbound = createGithubOutboundCallback({ token: tokenFn, logger, fetchImpl })
97
+ const outbound = createGithubOutboundCallback({
98
+ token: tokenFn,
99
+ authType: options.secrets.auth.type,
100
+ logger,
101
+ fetchImpl,
102
+ })
76
103
  const history = createGithubHistoryCallback({
77
104
  token: tokenFn,
78
105
  fetchImpl,
@@ -84,12 +111,14 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
84
111
  // No-op typing callback: GitHub has no typing indicator API.
85
112
  const typing = async (): Promise<void> => {}
86
113
  const dedup = createDeliveryDedup()
114
+ const isBotInTeam = createTeamMembershipChecker({ token: tokenFn, fetchImpl })
87
115
  const handler = createGithubWebhookHandler({
88
116
  webhookSecret,
89
117
  dedup,
90
118
  allowlist: () => options.configRef().eventAllowlist,
91
119
  selfId: () => selfId,
92
120
  selfLogin: () => selfLogin,
121
+ isBotInTeam,
93
122
  logger,
94
123
  route: (message) => {
95
124
  rememberWorkspace(message.workspace, message.chat)
@@ -140,6 +169,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
140
169
  process.env.GH_TOKEN = await auth.token()
141
170
  started = true
142
171
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
172
+ // Best-effort: App-only preflight that compares the installation's granted
173
+ // permissions against the configured eventAllowlist and warns about gaps.
174
+ // Catches the most common misconfiguration (App installed with the default
175
+ // metadata-only permission set) before any event fires a 403.
176
+ await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
143
177
  // Repository webhook registration is best-effort: failures are logged
144
178
  // per-repo, the adapter stays up. A misconfigured PAT or App that
145
179
  // can't manage hooks must not prevent the adapter from accepting
@@ -162,6 +196,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
162
196
  })
163
197
  } else if (repos.length > 0) {
164
198
  const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
199
+ if (webhookRegistrationDelayMs > 0) {
200
+ logger.info(
201
+ `[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
202
+ )
203
+ await sleep(webhookRegistrationDelayMs)
204
+ }
165
205
  const registration = await registerGithubWebhooks({
166
206
  token: tokenFn,
167
207
  webhookUrl: effectiveUrl,
@@ -290,72 +330,22 @@ function logRegistrationOutcome(
290
330
  }
291
331
  }
292
332
 
293
- // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
294
- // Returns the status code when it matches the two shapes GitHub emits for
295
- // missing access on the list-hooks endpoint:
296
- // - 404 Not Found: the token cannot see the repo at all (private repo
297
- // gated behind missing repository access — GitHub returns 404 instead of
298
- // 403 to avoid leaking the existence of private repos).
299
- // - 403 Forbidden: the token sees the repo but lacks webhook-management
300
- // permission, OR is blocked by an org SSO/SAML authorization gate.
301
- // Returns null for any other error (network, malformed slug, create-hook
302
- // failures, etc.) so the guidance only fires on the actual symptom.
303
- export function parseListHooksPermissionStatus(error: string): number | null {
304
- const match = error.match(/^list hooks failed: (404|403)\b/)
305
- if (match === null) return null
306
- return Number(match[1])
307
- }
308
-
309
- // The labels below intentionally mirror github.com's current UI verbatim so a
310
- // user can grep their settings page for the exact string. If GitHub renames
311
- // any of these in a future redesign, update both here and the
312
- // `permissionGuidance` tests in lifecycle.test.ts.
313
- //
314
- // Fine-grained PAT:
315
- // Settings → Developer settings → Personal access tokens → Fine-grained tokens
316
- // "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
317
- // GitHub App:
318
- // Settings → Developer settings → GitHub Apps → <app> → Permissions & events
319
- // "Repository permissions" → "Webhooks" → "Read and write"
320
- // Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
321
- // Classic PAT (legacy, still supported by GitHub but we don't surface it in
322
- // channel-add prompts):
323
- // Settings → Developer settings → Personal access tokens (classic)
324
- // Scope: "admin:repo_hook" (or full "repo" for private repositories)
325
- export function buildPermissionGuidance(
326
- authType: 'pat' | 'app',
327
- failures: ReadonlyArray<{ repo: string; status: number }>,
328
- ): string {
329
- const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
330
- const lines: string[] = [
331
- `[github] webhook setup needs more access for: ${repoList}.`,
332
- ' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
333
- ' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
334
- '',
335
- ]
336
- if (authType === 'pat') {
337
- lines.push(
338
- ' Fix (fine-grained personal access token):',
339
- ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
340
- ' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
341
- ' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
342
- ' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
343
- ' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
344
- '',
345
- ' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
346
- ' and on a SAML-protected org click "Authorize" next to the token.',
347
- )
348
- } else {
349
- lines.push(
350
- ' Fix (GitHub App):',
351
- ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
352
- ' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
353
- ' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
354
- ' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
355
- ' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
356
- )
333
+ async function runAppPermissionPreflight(
334
+ logger: GithubAdapterLogger,
335
+ auth: ReturnType<typeof buildAuthStrategy>,
336
+ eventAllowlist: readonly string[],
337
+ ): Promise<void> {
338
+ if (auth.getInstallationGrants === undefined) return
339
+ let grants
340
+ try {
341
+ grants = await auth.getInstallationGrants()
342
+ } catch (err) {
343
+ logger.warn(`[github] permission preflight skipped: ${err instanceof Error ? err.message : String(err)}`)
344
+ return
357
345
  }
358
- return lines.join('\n')
346
+ const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
347
+ if (gaps.length === 0) return
348
+ logger.warn(buildAppPermissionPreflightGuidance(gaps))
359
349
  }
360
350
 
361
351
  function logDeregistrationOutcome(
@@ -368,3 +358,7 @@ function logDeregistrationOutcome(
368
358
  else logger.warn(`[github] webhook detach failed for ${h.repo}#${h.hookId}: ${h.error ?? 'unknown error'}`)
369
359
  }
370
360
  }
361
+
362
+ function defaultSleep(ms: number): Promise<void> {
363
+ return new Promise((resolve) => setTimeout(resolve, ms))
364
+ }