typeclaw 0.17.0 → 0.19.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 (50) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +2 -1
  5. package/src/agent/model-overrides.ts +77 -0
  6. package/src/agent/plugin-tools.ts +53 -4
  7. package/src/agent/tools/grant-role.ts +102 -8
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  12. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  13. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  14. package/src/channels/adapters/discord-bot.ts +22 -4
  15. package/src/channels/adapters/github/auth-app.ts +49 -26
  16. package/src/channels/adapters/github/auth-pat.ts +3 -3
  17. package/src/channels/adapters/github/auth.ts +19 -5
  18. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  19. package/src/channels/adapters/github/history.ts +3 -2
  20. package/src/channels/adapters/github/inbound.ts +30 -55
  21. package/src/channels/adapters/github/index.ts +147 -43
  22. package/src/channels/adapters/github/membership.ts +7 -2
  23. package/src/channels/adapters/github/outbound.ts +6 -2
  24. package/src/channels/adapters/github/team-membership.ts +4 -2
  25. package/src/channels/adapters/github/webhook-register.ts +19 -16
  26. package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
  27. package/src/channels/adapters/slack-bot.ts +119 -18
  28. package/src/channels/commands.ts +10 -0
  29. package/src/channels/engagement.ts +34 -3
  30. package/src/channels/github-token-bridge.ts +42 -0
  31. package/src/channels/index.ts +6 -0
  32. package/src/channels/manager.ts +6 -0
  33. package/src/channels/membership.ts +9 -0
  34. package/src/channels/router.ts +155 -37
  35. package/src/cli/channel.ts +0 -12
  36. package/src/cli/init.ts +0 -9
  37. package/src/cli/ui.ts +6 -0
  38. package/src/commands/index.ts +54 -4
  39. package/src/init/dockerfile.ts +60 -0
  40. package/src/init/github-webhook-install.ts +1 -2
  41. package/src/init/index.ts +4 -10
  42. package/src/init/validate-api-key.ts +15 -1
  43. package/src/plugin/context.ts +8 -0
  44. package/src/plugin/manager.ts +3 -0
  45. package/src/plugin/types.ts +6 -0
  46. package/src/run/bundled-plugins.ts +9 -0
  47. package/src/run/index.ts +6 -0
  48. package/src/secrets/schema.ts +0 -1
  49. package/src/server/command-runner.ts +14 -0
  50. package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
package/auth.schema.json CHANGED
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.17.0",
3
+ "version": "0.19.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.0",
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",
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
@@ -26,6 +26,7 @@ import { getAuthFor } from './auth'
26
26
  import { createCompactionSettingsManager } from './compaction'
27
27
  import { renderGitNudge } from './git-nudge'
28
28
  import type { LiveSubagentRegistry } from './live-subagents'
29
+ import { applyModelRuntimeOverrides } from './model-overrides'
29
30
  import { createChannelLookAtTool, lookAtTool } from './multimodal'
30
31
  import {
31
32
  buildBuiltinPiToolOverrides,
@@ -357,7 +358,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
357
358
  ? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
358
359
  : customToolsPreBudget
359
360
 
360
- const model = resolveModel(activeRef)
361
+ const model = applyModelRuntimeOverrides(resolveModel(activeRef), activeRef)
361
362
  const thinkingLevel = defaultThinkingLevelForRef(activeRef)
362
363
  const { session } = await createAgentSession({
363
364
  model,
@@ -0,0 +1,77 @@
1
+ import type { Api, Model } from '@mariozechner/pi-ai'
2
+
3
+ import { providerForModelRef, type KnownModelRef, type KnownProviderId } from '@/config/providers'
4
+
5
+ // Providers whose base URL can be swapped to an upstream-compatible gateway at
6
+ // runtime. Each env var mirrors the upstream SDK's own name so a credential /
7
+ // endpoint pair that works with the official CLI carries over:
8
+ // * ANTHROPIC_BASE_URL — native Anthropic Messages protocol (`/v1/messages`,
9
+ // `x-api-key` / OAuth Bearer). NOT raw AWS Bedrock (SigV4, different
10
+ // transport) — that needs a distinct transport, not a base-URL swap.
11
+ // * OPENAI_BASE_URL — OpenAI-compatible endpoints (LiteLLM, Azure-style
12
+ // gateways, corporate proxies) speaking the same request shape as
13
+ // api.openai.com. Targets the `openai` provider only; `openai-codex` is
14
+ // an OAuth-only ChatGPT backend, not an API-key endpoint, so it is out of
15
+ // scope here.
16
+ export const PROVIDER_BASE_URL_ENV = {
17
+ anthropic: 'ANTHROPIC_BASE_URL',
18
+ openai: 'OPENAI_BASE_URL',
19
+ } as const satisfies Partial<Record<KnownProviderId, string>>
20
+
21
+ type OverridableProviderId = keyof typeof PROVIDER_BASE_URL_ENV
22
+
23
+ // Separate from `resolveModel` so resolution stays a pure table lookup; this
24
+ // is the per-process "prepare the model" seam run just before pi-coding-agent
25
+ // receives it. Clones because the `KNOWN_PROVIDERS` literals are shared static
26
+ // data that must never be mutated.
27
+ export function applyModelRuntimeOverrides<TApi extends Api>(
28
+ model: Model<TApi>,
29
+ ref: KnownModelRef,
30
+ env: NodeJS.ProcessEnv = process.env,
31
+ ): Model<TApi> {
32
+ const providerId = providerForModelRef(ref)
33
+ if (!isOverridable(providerId)) return model
34
+
35
+ const baseUrl = normalizeBaseUrl(PROVIDER_BASE_URL_ENV[providerId], env[PROVIDER_BASE_URL_ENV[providerId]])
36
+ if (baseUrl === undefined) return model
37
+
38
+ return { ...model, baseUrl }
39
+ }
40
+
41
+ // Resolves the effective base URL for a provider, falling back to the provider
42
+ // default when the override is unset. Returns `undefined` for providers without
43
+ // a base-URL override so callers can keep their hardcoded probe URL. Used by
44
+ // callers outside the session path (e.g. the init-time API-key probe) that need
45
+ // to hit the same endpoint the runtime will use.
46
+ export function effectiveBaseUrl(
47
+ providerId: KnownProviderId,
48
+ fallback: string,
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ ): string | undefined {
51
+ if (!isOverridable(providerId)) return undefined
52
+ const envVar = PROVIDER_BASE_URL_ENV[providerId]
53
+ return normalizeBaseUrl(envVar, env[envVar]) ?? fallback
54
+ }
55
+
56
+ function isOverridable(providerId: KnownProviderId): providerId is OverridableProviderId {
57
+ return providerId in PROVIDER_BASE_URL_ENV
58
+ }
59
+
60
+ // `undefined` for unset/blank (caller keeps the default); throws on a value
61
+ // that isn't a parseable http(s) URL so a typo fails loudly at boot rather
62
+ // than silently falling back to the public API with the wrong credential.
63
+ function normalizeBaseUrl(envVar: string, value: string | undefined): string | undefined {
64
+ const trimmed = value?.trim()
65
+ if (trimmed === undefined || trimmed === '') return undefined
66
+
67
+ let url: URL
68
+ try {
69
+ url = new URL(trimmed)
70
+ } catch {
71
+ throw new Error(`${envVar} is not a valid URL: ${trimmed}`)
72
+ }
73
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
74
+ throw new Error(`${envVar} must use http:// or https://, got: ${trimmed}`)
75
+ }
76
+ return url.toString().replace(/\/+$/, '')
77
+ }
@@ -1,6 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
1
3
  import type { AgentTool } from '@mariozechner/pi-agent-core'
2
4
  import {
3
- bashTool as piBashTool,
5
+ createBashTool as piCreateBashTool,
4
6
  defineTool as piDefineTool,
5
7
  editTool as piEditTool,
6
8
  findTool as piFindTool,
@@ -9,7 +11,7 @@ import {
9
11
  readTool as piReadTool,
10
12
  writeTool as piWriteTool,
11
13
  } from '@mariozechner/pi-coding-agent'
12
- import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
14
+ import type { BashSpawnContext, ToolDefinition } from '@mariozechner/pi-coding-agent'
13
15
  import type { Static, TSchema } from '@sinclair/typebox'
14
16
  import { Type } from '@sinclair/typebox'
15
17
  import { z } from 'zod'
@@ -46,6 +48,38 @@ import { websearchTool } from './tools/websearch'
46
48
  // and pi-coding-agent builtins — through one chokepoint.
47
49
  let sharedLoopGuard: LoopGuard = createLoopGuard()
48
50
 
51
+ // Internal, non-model-facing contract: a tool.before hook may set this key on
52
+ // a bash call's args to inject env vars into the spawned process WITHOUT
53
+ // putting them in the command string (where they would leak through logs and
54
+ // later hooks). The wrapper extracts and deletes it before the bash tool runs,
55
+ // then threads it to the spawn (non-sandboxed) and to bwrap --setenv
56
+ // (sandboxed). Used by github-cli-auth to inject a per-repo GH_TOKEN. The key
57
+ // is stripped from client-supplied args before tool.before so only trusted
58
+ // hooks can set it.
59
+ export const TYPECLAW_INTERNAL_BASH_ENV = '__typeclawBashEnv'
60
+
61
+ type BashEnvOverlay = Record<string, string>
62
+
63
+ const bashEnvStore = new AsyncLocalStorage<BashEnvOverlay | undefined>()
64
+
65
+ function readBashEnvOverlay(args: Record<string, unknown>): BashEnvOverlay | undefined {
66
+ const raw = args[TYPECLAW_INTERNAL_BASH_ENV]
67
+ if (raw === null || typeof raw !== 'object') return undefined
68
+ const overlay: BashEnvOverlay = {}
69
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
70
+ if (typeof value === 'string') overlay[key] = value
71
+ }
72
+ return Object.keys(overlay).length > 0 ? overlay : undefined
73
+ }
74
+
75
+ function bashSpawnHookWithOverlay(context: BashSpawnContext): BashSpawnContext {
76
+ const overlay = bashEnvStore.getStore()
77
+ if (overlay === undefined) return context
78
+ return { ...context, env: { ...context.env, ...overlay } }
79
+ }
80
+
81
+ const piBashTool = piCreateBashTool(process.cwd(), { spawnHook: bashSpawnHookWithOverlay })
82
+
49
83
  const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
50
84
  Type.Object(
51
85
  {
@@ -372,6 +406,9 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
372
406
  async execute(toolCallId, params, signal, onUpdate) {
373
407
  const mutableArgs = params as Record<string, unknown>
374
408
  const liveOrigin = opts.getOrigin?.()
409
+ // Defense-in-depth: strip any pre-existing internal env-overlay key
410
+ // before hooks run so only trusted tool.before hooks can set it.
411
+ delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
375
412
  const blockResult = await opts.hooks.runToolBefore({
376
413
  tool: tool.name,
377
414
  sessionId: opts.sessionId,
@@ -382,6 +419,11 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
382
419
  if (blockResult !== undefined) {
383
420
  throw new Error(`blocked: ${blockResult.reason}`)
384
421
  }
422
+ // Extract and delete before the loop guard serializes args and before
423
+ // the bash tool destructures them, so the overlay never reaches logs,
424
+ // loop-detection state, or pi's execute.
425
+ const bashEnvOverlay = readBashEnvOverlay(mutableArgs)
426
+ delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
385
427
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
386
428
  if (loopDecision.kind === 'block') {
387
429
  throw new Error(loopDecision.message)
@@ -401,10 +443,12 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
401
443
  stripGuardAcknowledgements(mutableArgs)
402
444
 
403
445
  if (tool.name === 'bash' && opts.permissions !== undefined) {
404
- await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir)
446
+ await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, bashEnvOverlay)
405
447
  }
406
448
 
407
- const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
449
+ const result = await bashEnvStore.run(bashEnvOverlay, () =>
450
+ tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate),
451
+ )
408
452
  const hookResult: ToolResult = {
409
453
  content: result.content as ContentPart[],
410
454
  details: result.details,
@@ -446,6 +490,7 @@ async function applyBashSandbox(
446
490
  permissions: PermissionService,
447
491
  origin: SessionOrigin | undefined,
448
492
  agentDir: string,
493
+ envOverlay: BashEnvOverlay | undefined,
449
494
  ): Promise<void> {
450
495
  const command = mutableArgs.command
451
496
  if (typeof command !== 'string') return
@@ -454,11 +499,15 @@ async function applyBashSandbox(
454
499
  if (dirs.length === 0 && files.length === 0) return
455
500
 
456
501
  await ensureBwrapAvailable()
502
+ // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
503
+ // it would never reach the sandboxed process (the non-sandboxed spawnHook
504
+ // path does not run when the command is rewritten to a bwrap invocation).
457
505
  const { commandString } = buildSandboxedCommand(command, {
458
506
  mounts: [{ type: 'bind', source: agentDir, dest: agentDir }],
459
507
  masks: { dirs, files },
460
508
  network: 'inherit',
461
509
  cwd: agentDir,
510
+ ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
462
511
  })
463
512
  mutableArgs.command = commandString
464
513
  }
@@ -1,6 +1,7 @@
1
1
  import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
+ import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
4
5
  import {
5
6
  grantRole,
6
7
  grantRolePermission,
@@ -48,11 +49,10 @@ function isTierRole(role: string): role is TierRole {
48
49
 
49
50
  // A single-principal turn carries only the principal's own first-party words:
50
51
  // 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
52
+ // third-party messages buffered in). A group/open channel turn normally mixes
53
+ // in other authors' messages, which is the confused-deputy surface that lets a
53
54
  // 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.
55
+ // Role grants are confined to turns where that surface does not exist.
56
56
  function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
57
57
  if (origin === undefined) return false
58
58
  if (origin.kind === 'tui') return true
@@ -60,6 +60,91 @@ function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
60
60
  return false
61
61
  }
62
62
 
63
+ // Shared precondition for treating a non-DM channel as injection-equivalent to
64
+ // a 1:1 DM. A DM is safe because it is "principal + the agent's OWN bot, no
65
+ // third-party content". For a group channel we must independently prove the
66
+ // same room shape from a membership read:
67
+ // - fresh and NOT truncated, so the count is the complete current membership
68
+ // (`participants` is speaker-only and cannot see silent lurkers — never
69
+ // used for authorization here);
70
+ // - `bots === 1`, i.e. the only non-human is the agent itself. The agent's
71
+ // own bot is always a member of a chat channel and is never an inbound
72
+ // author (adapters drop self-authored messages), so a complete read with
73
+ // exactly one bot proves there are NO peer bots whose buffered messages
74
+ // could prompt-inject the turn. A peer bot would push the count to >= 2
75
+ // (or, if misclassified as human, trip the human checks) — fail-closed
76
+ // either way.
77
+ // GitHub is excluded: its membership is the repo COLLABORATOR list, a different
78
+ // population from the authors that can comment into a PR/issue turn (and the
79
+ // agent App is typically not a collaborator), so `bots === 1` is not a valid
80
+ // "no peer bot" proof there. GitHub grants stay confined to the TUI/DM path.
81
+ function provesOnlyAgentBotPresent(
82
+ origin: Extract<SessionOrigin, { kind: 'channel' }>,
83
+ now: number,
84
+ ): origin is Extract<SessionOrigin, { kind: 'channel' }> & { membership: MembershipCount } {
85
+ if (origin.adapter === 'github') return false
86
+ const membership = origin.membership
87
+ if (membership === undefined) return false
88
+ if (membership.truncated) return false
89
+ if (now - membership.fetchedAt >= MEMBERSHIP_FRESHNESS_MS) return false
90
+ return membership.bots === 1
91
+ }
92
+
93
+ // A group/open channel that the platform proves contains exactly one human AND
94
+ // no peer bots is injection-equivalent to a 1:1 DM: there is no third-party
95
+ // author (human or bot) whose buffered messages could prompt-inject the turn.
96
+ // The lone human is the caller, and the per-turn caller-role check below still
97
+ // requires them to resolve to owner/trusted.
98
+ function isSingleHumanGroupChannelOrigin(origin: SessionOrigin | undefined, now: number): boolean {
99
+ if (origin?.kind !== 'channel') return false
100
+ if (isDmChannelOrigin(origin)) return false
101
+ if (!provesOnlyAgentBotPresent(origin, now)) return false
102
+
103
+ return origin.membership.humans === 1
104
+ }
105
+
106
+ // Caps the per-member role resolution this check performs so it can never do
107
+ // unbounded work on a large room. resolveRole is in-memory (a match-rule walk,
108
+ // no I/O), so the real cost is small, but a trusted-only operational channel is
109
+ // small by nature and past this many humans we refuse rather than iterate an
110
+ // arbitrarily long list on a tool call. Adapters already stop enumerating past
111
+ // their own cap; this is the guard-local ceiling.
112
+ const MAX_TRUSTED_GROUP_HUMANS = 20
113
+
114
+ // Generalises the single-human case: a group channel where the platform proves
115
+ // EVERY human member resolves to trusted/owner AND no peer bots are present is
116
+ // also injection-equivalent to a DM, because no untrusted author (human or bot)
117
+ // can buffer a message into the turn. The human proof requires an authoritative,
118
+ // complete identity enumeration — only a fresh, non-truncated membership read
119
+ // that carries `humanMemberIds` (the adapter listed and classified every member
120
+ // in one pass). `humanMemberIds` length must equal `humans` so an unaccounted
121
+ // member cannot slip past; the resolvers construct it that way and we re-check
122
+ // defensively. The no-peer-bot proof is shared with the single-human branch via
123
+ // provesOnlyAgentBotPresent (also enforces fresh/non-truncated and excludes
124
+ // GitHub). The room must be at most MAX_TRUSTED_GROUP_HUMANS humans. Each id is
125
+ // resolved through the same per-author path the turn anchor uses.
126
+ function isAllHumansTrustedGroupChannelOrigin(
127
+ origin: SessionOrigin | undefined,
128
+ permissions: PermissionService,
129
+ now: number,
130
+ ): boolean {
131
+ if (origin?.kind !== 'channel') return false
132
+ if (isDmChannelOrigin(origin)) return false
133
+ if (!provesOnlyAgentBotPresent(origin, now)) return false
134
+
135
+ const membership = origin.membership
136
+ const humanMemberIds = membership.humanMemberIds
137
+ if (humanMemberIds === undefined) return false
138
+ if (humanMemberIds.length !== membership.humans) return false
139
+ if (humanMemberIds.length === 0) return false
140
+ if (humanMemberIds.length > MAX_TRUSTED_GROUP_HUMANS) return false
141
+
142
+ return humanMemberIds.every((authorId) => {
143
+ const role = permissions.resolveRole({ ...origin, lastInboundAuthorId: authorId })
144
+ return role === 'owner' || role === 'trusted'
145
+ })
146
+ }
147
+
63
148
  export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
64
149
  const { agentDir, getOrigin, permissions, reloadRoles } = options
65
150
 
@@ -70,7 +155,9 @@ export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
70
155
  'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
71
156
  'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
72
157
  '("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. ' +
158
+ 'Only callable by an owner or trusted user from the TUI, a 1:1 DM, or a group channel with no peer bots whose ' +
159
+ 'human members are all trusted (or which has a single human member) — channels that admit untrusted humans or ' +
160
+ 'other bots cannot use it. ' +
74
161
  'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
75
162
  parameters: Type.Object({
76
163
  role: Type.Union(
@@ -98,10 +185,17 @@ export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
98
185
  async execute(_toolCallId, params): Promise<ToolReturn> {
99
186
  const origin = getOrigin()
100
187
 
101
- if (!isSinglePrincipalOrigin(origin)) {
188
+ const now = Date.now()
189
+ if (
190
+ !isSinglePrincipalOrigin(origin) &&
191
+ !isSingleHumanGroupChannelOrigin(origin, now) &&
192
+ !isAllHumansTrustedGroupChannelOrigin(origin, permissions, now)
193
+ ) {
102
194
  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).',
195
+ 'grant_role is only available from the TUI, a 1:1 DM, or a group channel that has no peer bots and whose ' +
196
+ 'human members are all trusted (or which currently has a single human member). A channel that admits any ' +
197
+ 'untrusted human or another bot cannot change roles, because it mixes in other participants\u2019 messages ' +
198
+ '(prompt-injection surface).',
105
199
  )
106
200
  }
107
201