typeclaw 0.16.0 → 0.18.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/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +32 -1
- package/src/agent/session-origin.ts +54 -12
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/grant-role.ts +214 -0
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +1 -0
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/index.ts +85 -43
- package/src/channels/adapters/github/membership.ts +3 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +76 -1
- package/src/channels/adapters/slack-bot.ts +115 -14
- package/src/channels/router.ts +87 -17
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +9 -43
- package/src/init/run-owner-claim.ts +21 -3
- package/src/permissions/builtins.ts +14 -4
- package/src/permissions/grant.ts +92 -16
- package/src/permissions/index.ts +8 -2
- package/src/permissions/permissions.ts +9 -0
- package/src/permissions/resolve.ts +10 -0
- package/src/role-claim/index.ts +1 -0
- package/src/role-claim/reload-after-claim.ts +34 -0
- package/src/run/channel-session-factory.ts +6 -1
- package/src/run/index.ts +20 -1
- package/src/sandbox/build.ts +32 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
- package/src/skills/typeclaw-permissions/SKILL.md +11 -3
- package/src/skills/typeclaw-skills/SKILL.md +3 -1
package/auth.schema.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.19.
|
|
49
|
+
"agent-messenger": "2.19.1",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
package/secrets.schema.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
|
9
9
|
import type { ChannelRouter } from '@/channels/router'
|
|
10
10
|
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
11
11
|
import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
12
|
-
import type { PermissionService } from '@/permissions'
|
|
12
|
+
import type { PermissionService, RolesConfig } from '@/permissions'
|
|
13
13
|
import type {
|
|
14
14
|
BuiltinToolRef,
|
|
15
15
|
HookBus,
|
|
@@ -50,6 +50,7 @@ import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachme
|
|
|
50
50
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
51
51
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
52
52
|
import { createChannelSendTool } from './tools/channel-send'
|
|
53
|
+
import { createGrantRoleTool } from './tools/grant-role'
|
|
53
54
|
import { createRestartTool } from './tools/restart'
|
|
54
55
|
import { createSkipResponseTool } from './tools/skip-response'
|
|
55
56
|
import { createSpawnSubagentTool } from './tools/spawn-subagent'
|
|
@@ -141,6 +142,11 @@ export type CreateSessionOptions = {
|
|
|
141
142
|
// prompt is not regenerated; see `typeclaw-permissions` skill for how the
|
|
142
143
|
// agent should interpret the snapshot on later turns.
|
|
143
144
|
permissions?: PermissionService
|
|
145
|
+
// Re-reads roles from disk for the grant_role tool's hot-reload after a match
|
|
146
|
+
// grant. Production threads a reload-then-read (reloadConfig + getConfig);
|
|
147
|
+
// must not be an in-memory snapshot or the grant reapplies stale roles.
|
|
148
|
+
// Omitted when no grant_role tool is wired (the tool requires permissions).
|
|
149
|
+
reloadRoles?: () => RolesConfig | undefined
|
|
144
150
|
// Model profile name. Resolved against `config.models` to pick the concrete
|
|
145
151
|
// model ref this session binds to. Unknown profile names fall back to
|
|
146
152
|
// `default` with a one-time console warning. Omitted → `default`. Threaded
|
|
@@ -322,6 +328,12 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
322
328
|
permissions: options.permissions,
|
|
323
329
|
stream: options.stream,
|
|
324
330
|
}),
|
|
331
|
+
...buildRoleGrantTools({
|
|
332
|
+
agentDir: options.plugins?.agentDir,
|
|
333
|
+
getOrigin,
|
|
334
|
+
permissions: options.permissions,
|
|
335
|
+
reloadRoles: options.reloadRoles,
|
|
336
|
+
}),
|
|
325
337
|
]
|
|
326
338
|
// Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
|
|
327
339
|
// find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
|
|
@@ -577,6 +589,25 @@ export function buildSubagentOrchestrationTools(opts: {
|
|
|
577
589
|
]
|
|
578
590
|
}
|
|
579
591
|
|
|
592
|
+
export function buildRoleGrantTools(opts: {
|
|
593
|
+
agentDir: string | undefined
|
|
594
|
+
getOrigin: () => SessionOrigin | undefined
|
|
595
|
+
permissions: PermissionService | undefined
|
|
596
|
+
reloadRoles: (() => RolesConfig | undefined) | undefined
|
|
597
|
+
}): ToolDefinition[] {
|
|
598
|
+
if (opts.agentDir === undefined || opts.permissions === undefined || opts.reloadRoles === undefined) {
|
|
599
|
+
return []
|
|
600
|
+
}
|
|
601
|
+
return [
|
|
602
|
+
createGrantRoleTool({
|
|
603
|
+
agentDir: opts.agentDir,
|
|
604
|
+
getOrigin: opts.getOrigin,
|
|
605
|
+
permissions: opts.permissions,
|
|
606
|
+
reloadRoles: opts.reloadRoles,
|
|
607
|
+
}),
|
|
608
|
+
]
|
|
609
|
+
}
|
|
610
|
+
|
|
580
611
|
function wrapRegistryTools(
|
|
581
612
|
plugins: PluginSessionWiring | undefined,
|
|
582
613
|
getOrigin: () => SessionOrigin | undefined,
|
|
@@ -111,11 +111,12 @@ function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
|
111
111
|
// TUI is always `owner` by construction — annotating it would add noise to
|
|
112
112
|
// every interactive session for zero new information.
|
|
113
113
|
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
114
|
+
// Channel origins do NOT render this concrete role. A channel session is
|
|
115
|
+
// keyed by chat/thread, so the opener's role is wrong for every later
|
|
116
|
+
// speaker and printing their permission list leaks it into shared context.
|
|
117
|
+
// Channel origins render renderChannelRolePolicy() instead, and the
|
|
118
|
+
// authoritative per-turn role rides in the non-cacheable `<your-role>`
|
|
119
|
+
// turn anchor (renderTurnRoleAnchor in system-prompt.ts).
|
|
119
120
|
export type SessionRoleContext = {
|
|
120
121
|
role: string
|
|
121
122
|
permissions: readonly string[]
|
|
@@ -128,21 +129,22 @@ export function renderSessionOrigin(
|
|
|
128
129
|
): string {
|
|
129
130
|
switch (origin.kind) {
|
|
130
131
|
case 'tui':
|
|
131
|
-
return withRoleContext(renderTuiOrigin(), roleContext)
|
|
132
|
+
return withRoleContext(renderTuiOrigin(), roleContext, origin.kind)
|
|
132
133
|
case 'cron':
|
|
133
|
-
return withRoleContext(renderCronOrigin(origin), roleContext)
|
|
134
|
+
return withRoleContext(renderCronOrigin(origin), roleContext, origin.kind)
|
|
134
135
|
case 'channel':
|
|
135
|
-
return withRoleContext(renderChannelOrigin(origin, now), roleContext)
|
|
136
|
+
return withRoleContext(renderChannelOrigin(origin, now), roleContext, origin.kind)
|
|
136
137
|
case 'subagent':
|
|
137
|
-
return withRoleContext(renderSubagentOrigin(origin), roleContext)
|
|
138
|
+
return withRoleContext(renderSubagentOrigin(origin), roleContext, origin.kind)
|
|
138
139
|
case 'system':
|
|
139
|
-
return withRoleContext(renderSystemOrigin(origin), roleContext)
|
|
140
|
+
return withRoleContext(renderSystemOrigin(origin), roleContext, origin.kind)
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
|
|
144
|
+
function withRoleContext(block: string, ctx: SessionRoleContext | undefined, kind: SessionOrigin['kind']): string {
|
|
144
145
|
if (ctx === undefined) return block
|
|
145
|
-
|
|
146
|
+
const roleBlock = kind === 'channel' ? renderChannelRolePolicy() : renderRoleContext(ctx)
|
|
147
|
+
return `${block}\n\n${roleBlock}`
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
function renderRoleContext(ctx: SessionRoleContext): string {
|
|
@@ -160,6 +162,30 @@ function renderRoleContext(ctx: SessionRoleContext): string {
|
|
|
160
162
|
].join('\n')
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
// Channel sessions are keyed by chat/thread, not by author: one session can see
|
|
166
|
+
// many speakers with different roles. Rendering the opener's concrete role here
|
|
167
|
+
// would (1) be wrong for every later speaker and (2) leak the opener's full
|
|
168
|
+
// permission list into shared context. So channel origins get a cache-stable
|
|
169
|
+
// policy instead of a resolved identity; the authoritative per-turn role rides
|
|
170
|
+
// in the non-cacheable `<your-role>` turn anchor.
|
|
171
|
+
function renderChannelRolePolicy(): string {
|
|
172
|
+
return [
|
|
173
|
+
'## Your role in this session',
|
|
174
|
+
'',
|
|
175
|
+
'This is a channel conversation that may include multiple speakers. Do not',
|
|
176
|
+
'assume one speaker’s role applies to later messages. For each user turn the',
|
|
177
|
+
'current speaker’s effective role is provided in the turn context as a',
|
|
178
|
+
'`<your-role>` tag; that per-turn role is authoritative for the current',
|
|
179
|
+
'message and overrides any role implied by session-opening context. An absent',
|
|
180
|
+
'`<your-role>` tag means the current speaker is the unconstrained default.',
|
|
181
|
+
'',
|
|
182
|
+
'Tool calls and channel admission are gated by the current speaker’s',
|
|
183
|
+
'permissions; a `blocked:` or "denied by permissions" message means that',
|
|
184
|
+
'speaker lacks the permission the guard wanted. See the',
|
|
185
|
+
'`typeclaw-permissions` skill for what each role can do.',
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
function renderTuiOrigin(): string {
|
|
164
190
|
return [
|
|
165
191
|
'## Session origin',
|
|
@@ -262,6 +288,22 @@ function renderChannelOrigin(
|
|
|
262
288
|
'is a tool call. Plain-text output is invisible.',
|
|
263
289
|
]
|
|
264
290
|
|
|
291
|
+
// GitHub has no separate "chat" surface — channel_reply IS a public comment
|
|
292
|
+
// on this PR/issue. Without saying so, models default to the Slack-style
|
|
293
|
+
// two-surface split and post operator-facing meta-commentary ("Posted review
|
|
294
|
+
// result for PR #511") straight into the PR thread, where it reads absurdly.
|
|
295
|
+
if (origin.adapter === 'github') {
|
|
296
|
+
lines.push(
|
|
297
|
+
'',
|
|
298
|
+
'**`channel_reply` posts a public comment directly on this PR/issue.** It',
|
|
299
|
+
'is not a side-report to an operator — the reply lands in this exact',
|
|
300
|
+
'thread, read by everyone on the PR. Write the substance for that',
|
|
301
|
+
'audience: post the answer (or review summary) itself, never a status',
|
|
302
|
+
'line about having posted it elsewhere. A narrated "Posted review result',
|
|
303
|
+
'for PR #N: …" inside the PR is exactly the failure to avoid.',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
265
307
|
const conversationLine = renderConversationLine(origin)
|
|
266
308
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
267
309
|
|
|
@@ -173,7 +173,7 @@ export function renderTurnTimeAnchor(now: Date = new Date()): string {
|
|
|
173
173
|
// block for a TUI owner.
|
|
174
174
|
export function renderTurnRoleAnchor(role: string): string | undefined {
|
|
175
175
|
if (role === 'owner') return undefined
|
|
176
|
-
return `<your-role>${role}</your-role
|
|
176
|
+
return `<your-role authority="current-speaker">${role}</your-role> (authoritative for this message; overrides any role implied by the system prompt)`
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
grantRole,
|
|
6
|
+
grantRolePermission,
|
|
7
|
+
isDmChannelOrigin,
|
|
8
|
+
parseMatchRule,
|
|
9
|
+
type PermissionService,
|
|
10
|
+
type RolesConfig,
|
|
11
|
+
} from '@/permissions'
|
|
12
|
+
|
|
13
|
+
import type { SessionOrigin } from '../session-origin'
|
|
14
|
+
|
|
15
|
+
export type GrantRoleToolDetails =
|
|
16
|
+
| { ok: true; mode: 'match' | 'permission'; role: string; value: string; added: boolean; restartRequired: boolean }
|
|
17
|
+
| { ok: false; error: string }
|
|
18
|
+
|
|
19
|
+
export type CreateGrantRoleToolOptions = {
|
|
20
|
+
agentDir: string
|
|
21
|
+
getOrigin: () => SessionOrigin | undefined
|
|
22
|
+
permissions: PermissionService
|
|
23
|
+
// Re-reads roles FROM DISK and returns the fresh set, for hot-reloading a
|
|
24
|
+
// match-grant after grantRole writes typeclaw.json. Must NOT read an
|
|
25
|
+
// in-memory config snapshot: grantRole writes the file directly, so a
|
|
26
|
+
// snapshot taken before the live config pointer is reloaded would be stale
|
|
27
|
+
// and replaceRoles would reapply the pre-grant table. Production wires this
|
|
28
|
+
// to reloadConfig(agentDir) + getConfig().roles, matching the config
|
|
29
|
+
// reloadable. A permission-grant is restart-required, so the reload has no
|
|
30
|
+
// runtime effect for it, but we reload anyway to keep a same-session
|
|
31
|
+
// subsequent match-grant reading a consistent table.
|
|
32
|
+
reloadRoles: () => RolesConfig | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Roles this tool may target, lowest to highest. `guest` is a valid target
|
|
36
|
+
// (an operator opening guest channel.respond) but never a granter — the gate
|
|
37
|
+
// below requires the caller to resolve to owner/trusted.
|
|
38
|
+
const TIER_ORDER = ['guest', 'member', 'trusted', 'owner'] as const
|
|
39
|
+
type TierRole = (typeof TIER_ORDER)[number]
|
|
40
|
+
|
|
41
|
+
function tierOf(role: string): number {
|
|
42
|
+
return TIER_ORDER.indexOf(role as TierRole)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTierRole(role: string): role is TierRole {
|
|
46
|
+
return (TIER_ORDER as readonly string[]).includes(role)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A single-principal turn carries only the principal's own first-party words:
|
|
50
|
+
// the TUI (a human typing directly) or a 1:1 DM (principal + bot, no
|
|
51
|
+
// third-party messages buffered in). A group/open channel turn always mixes in
|
|
52
|
+
// other authors' messages, which is the confused-deputy surface that lets a
|
|
53
|
+
// guest prompt-inject a trusted turn into rewriting the access-control table.
|
|
54
|
+
// Role grants are confined to single-principal turns so that surface does not
|
|
55
|
+
// exist.
|
|
56
|
+
function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
|
|
57
|
+
if (origin === undefined) return false
|
|
58
|
+
if (origin.kind === 'tui') return true
|
|
59
|
+
if (origin.kind === 'channel') return isDmChannelOrigin(origin)
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
|
|
64
|
+
const { agentDir, getOrigin, permissions, reloadRoles } = options
|
|
65
|
+
|
|
66
|
+
return defineTool({
|
|
67
|
+
name: 'grant_role',
|
|
68
|
+
label: 'Grant Role',
|
|
69
|
+
description:
|
|
70
|
+
'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
|
|
71
|
+
'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
|
|
72
|
+
'("let anyone in this channel message you" → grant guest channel.respond). ' +
|
|
73
|
+
'Only callable from the TUI or a 1:1 DM by an owner or trusted user — group-channel turns cannot use it. ' +
|
|
74
|
+
'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
|
|
75
|
+
parameters: Type.Object({
|
|
76
|
+
role: Type.Union(
|
|
77
|
+
[Type.Literal('owner'), Type.Literal('trusted'), Type.Literal('member'), Type.Literal('guest')],
|
|
78
|
+
{
|
|
79
|
+
description: 'The role to grant TO.',
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
match: Type.Optional(
|
|
83
|
+
Type.String({
|
|
84
|
+
description:
|
|
85
|
+
'A match rule assigning an author/scope to the role, e.g. "slack:T0123 author:U_WIFE". ' +
|
|
86
|
+
'Provide exactly one of match or permission.',
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
permission: Type.Optional(
|
|
90
|
+
Type.String({
|
|
91
|
+
description:
|
|
92
|
+
'A capability to add to the role, e.g. "channel.respond". Provide exactly one of match or permission. ' +
|
|
93
|
+
'security.bypass.* permissions cannot be granted through this tool.',
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
async execute(_toolCallId, params): Promise<ToolReturn> {
|
|
99
|
+
const origin = getOrigin()
|
|
100
|
+
|
|
101
|
+
if (!isSinglePrincipalOrigin(origin)) {
|
|
102
|
+
return err(
|
|
103
|
+
'grant_role is only available from the TUI or a 1:1 DM. A group-channel turn cannot change roles, ' +
|
|
104
|
+
'because it mixes in other participants\u2019 messages (prompt-injection surface).',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const callerRole = permissions.resolveRole(origin)
|
|
109
|
+
if (callerRole !== 'owner' && callerRole !== 'trusted') {
|
|
110
|
+
return err(`grant_role denied: caller resolves to '${callerRole}'; only owner or trusted may grant roles.`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasMatch = typeof params.match === 'string' && params.match.length > 0
|
|
114
|
+
const hasPermission = typeof params.permission === 'string' && params.permission.length > 0
|
|
115
|
+
if (hasMatch === hasPermission) {
|
|
116
|
+
return err('Provide exactly one of `match` or `permission`.')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!isTierRole(params.role)) {
|
|
120
|
+
return err(`Unknown target role '${params.role}'.`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tier ceiling: a granter may not assign or empower a role ABOVE its own.
|
|
124
|
+
// owner(3) ≥ trusted(2) ≥ member(1) ≥ guest(0).
|
|
125
|
+
if (tierOf(params.role) > tierOf(callerRole)) {
|
|
126
|
+
return err(`grant_role denied: a ${callerRole} caller cannot grant the higher '${params.role}' role.`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return hasMatch
|
|
130
|
+
? grantMatch(params.role, params.match as string)
|
|
131
|
+
: grantPermission(callerRole, params.role, params.permission as string)
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
function grantMatch(role: string, matchRule: string): ToolReturn {
|
|
136
|
+
const parsed = parseMatchRule(matchRule)
|
|
137
|
+
if (!parsed.ok) {
|
|
138
|
+
return err(`Invalid match rule '${matchRule}': ${parsed.error}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = grantRole({ cwd: agentDir, roleName: role, matchRule })
|
|
142
|
+
if (!result.ok) return err(result.reason)
|
|
143
|
+
|
|
144
|
+
reload()
|
|
145
|
+
return ok('match', role, matchRule, result.added, false)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function grantPermission(callerRole: string, role: string, permission: string): ToolReturn {
|
|
149
|
+
// security.bypass.* defeats guards rather than enabling a feature; never
|
|
150
|
+
// grantable through an agent tool. It stays a deliberate hand-edit gated by
|
|
151
|
+
// the rolePromotion guard + an explicit ack.
|
|
152
|
+
if (permission.startsWith('security.bypass.')) {
|
|
153
|
+
return err(
|
|
154
|
+
`grant_role refuses to grant '${permission}': security.bypass.* permissions disable security guards and ` +
|
|
155
|
+
'must be set by hand-editing typeclaw.json (gated by the rolePromotion guard), not via this tool.',
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// "Grant only what you hold": the caller cannot confer a capability it does
|
|
160
|
+
// not itself possess. Owner's resolved set is the expanded wildcard, so an
|
|
161
|
+
// owner can grant any non-bypass core permission; trusted is capped at its
|
|
162
|
+
// literal set.
|
|
163
|
+
const callerPerms = permissions.describe(getOrigin()).permissions
|
|
164
|
+
if (!callerPerms.includes(permission)) {
|
|
165
|
+
return err(
|
|
166
|
+
`grant_role denied: a ${callerRole} caller cannot grant '${permission}' because it does not hold that permission.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = grantRolePermission({ cwd: agentDir, roleName: role, permission })
|
|
171
|
+
if (!result.ok) return err(result.reason)
|
|
172
|
+
|
|
173
|
+
reload()
|
|
174
|
+
return ok('permission', role, permission, result.added, true)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function reload(): void {
|
|
178
|
+
try {
|
|
179
|
+
permissions.replaceRoles(reloadRoles())
|
|
180
|
+
} catch {
|
|
181
|
+
// Best-effort hot-reload of match-grants. A failure here does not undo
|
|
182
|
+
// the on-disk write; the next config reload / restart picks it up.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function ok(
|
|
188
|
+
mode: 'match' | 'permission',
|
|
189
|
+
role: string,
|
|
190
|
+
value: string,
|
|
191
|
+
added: boolean,
|
|
192
|
+
restartRequired: boolean,
|
|
193
|
+
): ToolReturn {
|
|
194
|
+
const details: GrantRoleToolDetails = { ok: true, mode, role, value, added, restartRequired }
|
|
195
|
+
const note = added ? '' : ' (already on file)'
|
|
196
|
+
const restart = restartRequired
|
|
197
|
+
? ' This permission grant is restart-required; run `typeclaw restart` for it to take effect.'
|
|
198
|
+
: ''
|
|
199
|
+
const text =
|
|
200
|
+
mode === 'match'
|
|
201
|
+
? `Granted role '${role}' the match rule '${value}'${note}.${restart}`
|
|
202
|
+
: `Granted role '${role}' the permission '${value}'${note}.${restart}`
|
|
203
|
+
return { content: [{ type: 'text', text }], details }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function err(message: string): ToolReturn {
|
|
207
|
+
const details: GrantRoleToolDetails = { ok: false, error: message }
|
|
208
|
+
return { content: [{ type: 'text', text: message }], details }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ToolReturn = {
|
|
212
|
+
content: { type: 'text'; text: string }[]
|
|
213
|
+
details: GrantRoleToolDetails
|
|
214
|
+
}
|
|
@@ -12,6 +12,7 @@ export type InboundDropReason =
|
|
|
12
12
|
| 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
|
|
13
13
|
| 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
|
|
14
14
|
| 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
|
|
15
|
+
| 'thread_created_system' // Discord's THREAD_CREATED system notice posted to the PARENT channel when a public thread is created from a message
|
|
15
16
|
|
|
16
17
|
export type InboundClassification =
|
|
17
18
|
| { kind: 'drop'; reason: InboundDropReason }
|
|
@@ -38,6 +39,10 @@ export function classifyInbound(
|
|
|
38
39
|
const { text, attachments } = splitInbound(event)
|
|
39
40
|
if (text === '') return { kind: 'drop', reason: 'empty_content' }
|
|
40
41
|
|
|
42
|
+
if (isThreadCreatedSystemMessage(event)) {
|
|
43
|
+
return { kind: 'drop', reason: 'thread_created_system' }
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
const isDm = event.guild_id === undefined
|
|
42
47
|
const workspace = isDm ? '@dm' : event.guild_id!
|
|
43
48
|
|
|
@@ -108,6 +113,24 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
|
|
|
108
113
|
return (event.mentions ?? []).some((m) => m.id === botUserId)
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
// Creating a public thread from a message fires TWO MESSAGE_CREATE events: a
|
|
117
|
+
// THREAD_CREATED notice in the parent channel (content = thread name) and a
|
|
118
|
+
// THREAD_STARTER_MESSAGE inside the thread. Each opens its own router session,
|
|
119
|
+
// so the agent replies twice. The numeric Discord message type (18) that would
|
|
120
|
+
// filter this cleanly is destroyed by the agent-messenger listener (it does
|
|
121
|
+
// `{ ...d, type: t }`, overwriting `d.type` with the dispatch name), so we
|
|
122
|
+
// fingerprint the notice by its reference instead: it points at a DIFFERENT
|
|
123
|
+
// channel (the new thread) with no source `message_id`. The message_id-absent
|
|
124
|
+
// check is load-bearing — it spares normal cross-channel replies and the
|
|
125
|
+
// in-thread starter, both of which DO carry a message_id.
|
|
126
|
+
function isThreadCreatedSystemMessage(event: DiscordGatewayMessageCreateEvent): boolean {
|
|
127
|
+
const ref = event.message_reference
|
|
128
|
+
if (ref?.channel_id === undefined) return false
|
|
129
|
+
if (ref.channel_id === event.channel_id) return false
|
|
130
|
+
if (ref.message_id !== undefined) return false
|
|
131
|
+
return ref.guild_id === undefined || event.guild_id === undefined || ref.guild_id === event.guild_id
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
112
135
|
|
|
113
136
|
function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
|
|
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
4
4
|
|
|
5
|
-
import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
5
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
6
6
|
import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
|
|
7
7
|
|
|
8
|
+
type TokenCacheEntry = { value: string; expiresAt: number }
|
|
9
|
+
|
|
8
10
|
export class AppAuthStrategy implements GithubAuthStrategy {
|
|
9
11
|
private readonly appId: number
|
|
10
12
|
private readonly privateKeyPem: string
|
|
11
|
-
private readonly installationId: number | null
|
|
12
13
|
private readonly fetchImpl: typeof fetch
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Keyed by installation id: a single App may span multiple owners, each a
|
|
15
|
+
// separate installation with its own short-lived token.
|
|
16
|
+
private readonly tokenCache = new Map<number, TokenCacheEntry>()
|
|
17
|
+
private readonly repoInstallationCache = new Map<string, number>()
|
|
18
|
+
private soleInstallationId: number | null = null
|
|
15
19
|
private _selfUser: GithubSelfUser | null = null
|
|
16
20
|
|
|
17
|
-
constructor(options: { appId: number; privateKey: Secret;
|
|
21
|
+
constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
|
|
18
22
|
const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
|
|
19
23
|
if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
|
|
20
24
|
this.appId = options.appId
|
|
21
25
|
this.privateKeyPem = privateKeyPem
|
|
22
|
-
this.installationId = options.installationId ?? null
|
|
23
26
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
async token(): Promise<string> {
|
|
27
|
-
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
|
|
28
|
-
return this.cachedToken.value
|
|
29
|
-
}
|
|
29
|
+
async token(context?: GithubAuthContext): Promise<string> {
|
|
30
30
|
const jwt = await this.mintJwt()
|
|
31
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
31
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
32
|
+
return this.installationToken(jwt, installId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
|
|
36
|
+
return githubJsonHeaders(await this.token(context))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async installationToken(jwt: string, installId: number): Promise<string> {
|
|
40
|
+
const cached = this.tokenCache.get(installId)
|
|
41
|
+
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
|
|
32
42
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
|
|
33
43
|
method: 'POST',
|
|
34
44
|
headers: githubJsonHeaders(jwt),
|
|
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
37
47
|
const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
|
|
38
48
|
if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
|
|
39
49
|
const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
|
|
40
|
-
this.
|
|
50
|
+
this.tokenCache.set(installId, { value: raw.token, expiresAt })
|
|
41
51
|
return raw.token
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
45
|
-
return githubJsonHeaders(await this.token())
|
|
46
|
-
}
|
|
47
|
-
|
|
48
54
|
async getSelf(): Promise<GithubSelfUser> {
|
|
49
55
|
if (this._selfUser) return this._selfUser
|
|
50
56
|
const jwt = await this.mintJwt()
|
|
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
69
75
|
return this._selfUser
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
async getInstallationGrants(): Promise<GithubInstallationGrants> {
|
|
78
|
+
async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
|
|
73
79
|
const jwt = await this.mintJwt()
|
|
74
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
80
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
75
81
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
|
|
76
82
|
headers: githubJsonHeaders(jwt),
|
|
77
83
|
})
|
|
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
async dispose(): Promise<void> {
|
|
91
|
-
this.
|
|
97
|
+
this.tokenCache.clear()
|
|
98
|
+
this.repoInstallationCache.clear()
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
private async mintJwt(): Promise<string> {
|
|
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
107
114
|
return `${signingInput}.${base64url(Buffer.from(signature))}`
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
private async resolveInstallationId(jwt: string): Promise<number> {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
|
|
118
|
+
if (context?.repoSlug !== undefined && context.repoSlug !== '') {
|
|
119
|
+
return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
|
|
120
|
+
}
|
|
121
|
+
if (context?.owner !== undefined && context.owner !== '') {
|
|
122
|
+
return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
|
|
115
123
|
}
|
|
124
|
+
if (this.soleInstallationId !== null) return this.soleInstallationId
|
|
116
125
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
|
|
117
126
|
if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
|
|
118
127
|
const list = (await response.json()) as Array<{ id?: unknown }>
|
|
119
128
|
if (list.length === 0) throw new Error('GitHub App has no installations')
|
|
120
129
|
if (list.length > 1) {
|
|
121
130
|
const ids = list.map((installation) => installation.id).join(', ')
|
|
122
|
-
throw new Error(`GitHub App has multiple installations (${ids});
|
|
131
|
+
throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
|
|
123
132
|
}
|
|
124
133
|
const id = list[0]?.id
|
|
125
134
|
if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
|
|
126
|
-
this.
|
|
135
|
+
this.soleInstallationId = id
|
|
127
136
|
return id
|
|
128
137
|
}
|
|
138
|
+
|
|
139
|
+
private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
|
|
140
|
+
const cached = this.repoInstallationCache.get(target)
|
|
141
|
+
if (cached !== undefined) return cached
|
|
142
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
|
|
143
|
+
if (response.status === 404) {
|
|
144
|
+
throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
|
|
147
|
+
const raw = (await response.json()) as { id?: unknown }
|
|
148
|
+
if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
|
|
149
|
+
this.repoInstallationCache.set(target, raw.id)
|
|
150
|
+
return raw.id
|
|
151
|
+
}
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
function base64url(input: string | Buffer): string {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
2
2
|
|
|
3
|
-
import type { GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
3
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
4
4
|
|
|
5
5
|
export const GITHUB_API_BASE = 'https://api.github.com'
|
|
6
6
|
|
|
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
|
|
|
15
15
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
async token(): Promise<string> {
|
|
18
|
+
async token(_context?: GithubAuthContext): Promise<string> {
|
|
19
19
|
return this._token
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
22
|
+
async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
|
|
23
23
|
return githubJsonHeaders(this._token)
|
|
24
24
|
}
|
|
25
25
|
|