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.
- package/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- 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 `
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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 = '
|
|
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:
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
+
}
|