typeclaw 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot.ts +242 -7
- package/src/channels/adapters/github/inbound.ts +40 -55
- package/src/channels/adapters/github/index.ts +89 -18
- package/src/channels/adapters/github/membership.ts +4 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
- package/src/channels/adapters/slack-bot.ts +4 -4
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +30 -2
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +295 -42
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/validate-api-key.ts +15 -1
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -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,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
} from './tool-result-budget'
|
|
49
50
|
import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
|
|
50
51
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
52
|
+
import { createChannelReactTool } from './tools/channel-react'
|
|
51
53
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
52
54
|
import { createChannelSendTool } from './tools/channel-send'
|
|
53
55
|
import { createGrantRoleTool } from './tools/grant-role'
|
|
@@ -357,7 +359,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
357
359
|
? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
|
|
358
360
|
: customToolsPreBudget
|
|
359
361
|
|
|
360
|
-
const model = resolveModel(activeRef)
|
|
362
|
+
const model = applyModelRuntimeOverrides(resolveModel(activeRef), activeRef)
|
|
361
363
|
const thinkingLevel = defaultThinkingLevelForRef(activeRef)
|
|
362
364
|
const { session } = await createAgentSession({
|
|
363
365
|
model,
|
|
@@ -531,6 +533,12 @@ export function buildChannelTools(
|
|
|
531
533
|
tools.push(createChannelReplyTool({ router: channelRouter, origin: channelOrigin }))
|
|
532
534
|
tools.push(createChannelHistoryTool({ router: channelRouter, origin: channelOrigin }))
|
|
533
535
|
tools.push(createChannelSendTool({ router: channelRouter, origin: channelOrigin }))
|
|
536
|
+
tools.push(
|
|
537
|
+
createChannelReactTool({
|
|
538
|
+
router: channelRouter,
|
|
539
|
+
origin: { ...channelOrigin, ...(origin.reactionRef !== undefined ? { reactionRef: origin.reactionRef } : {}) },
|
|
540
|
+
}),
|
|
541
|
+
)
|
|
534
542
|
tools.push(
|
|
535
543
|
createChannelFetchAttachmentTool({
|
|
536
544
|
router: channelRouter,
|
|
@@ -19,6 +19,10 @@ export type LiveSubagent = {
|
|
|
19
19
|
sessionId: string
|
|
20
20
|
subagentName: string
|
|
21
21
|
parentSessionId?: string
|
|
22
|
+
// Role that resolved at spawn time, captured for the provenance cap on
|
|
23
|
+
// subagent_output/subagent_cancel. Absent when no permission service was
|
|
24
|
+
// active at spawn, in which case the cap fails closed.
|
|
25
|
+
spawnedByRole?: string
|
|
22
26
|
startedAt: number
|
|
23
27
|
status: SubagentStatus
|
|
24
28
|
completion?: SubagentCompletion
|
|
@@ -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
|
-
|
|
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
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
|
|
2
2
|
import type { AdapterId } from '@/channels/schema'
|
|
3
|
+
import type { ReactionRef } from '@/channels/types'
|
|
3
4
|
|
|
4
5
|
export type ChannelParticipant = {
|
|
5
6
|
authorId: string
|
|
@@ -38,6 +39,7 @@ export type SessionOrigin =
|
|
|
38
39
|
chatName?: string
|
|
39
40
|
thread: string | null
|
|
40
41
|
lastInboundAuthorId?: string
|
|
42
|
+
reactionRef?: ReactionRef
|
|
41
43
|
participants?: readonly ChannelParticipant[]
|
|
42
44
|
membership?: MembershipCount
|
|
43
45
|
}
|
|
@@ -301,6 +303,13 @@ function renderChannelOrigin(
|
|
|
301
303
|
'audience: post the answer (or review summary) itself, never a status',
|
|
302
304
|
'line about having posted it elsewhere. A narrated "Posted review result',
|
|
303
305
|
'for PR #N: …" inside the PR is exactly the failure to avoid.',
|
|
306
|
+
'',
|
|
307
|
+
'**Do not post an "On it" acknowledgment comment.** The runtime already',
|
|
308
|
+
'adds an :eyes: reaction to the triggering item the moment it engages, so a',
|
|
309
|
+
'separate "looking into this" comment is redundant noise on the PR. If you',
|
|
310
|
+
'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
|
|
311
|
+
'(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
|
|
312
|
+
'for the actual substantive answer.',
|
|
304
313
|
)
|
|
305
314
|
}
|
|
306
315
|
|
|
@@ -339,12 +348,21 @@ function renderChannelOrigin(
|
|
|
339
348
|
'`channel_reply` call, not narration. This includes acks.',
|
|
340
349
|
'',
|
|
341
350
|
'**One substantive reply per inbound.** If the answer needs more than one',
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
...(origin.adapter === 'github'
|
|
352
|
+
? [
|
|
353
|
+
'tool call, keep working and post the answer with a single final',
|
|
354
|
+
'`channel_reply`. Do not post an "On it" ack comment first — the runtime',
|
|
355
|
+
'already added an :eyes: reaction on engage; use `channel_react` if you',
|
|
356
|
+
'want to acknowledge explicitly. The answer is your reply.',
|
|
357
|
+
]
|
|
358
|
+
: [
|
|
359
|
+
'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
|
|
360
|
+
'continue: true })`, keep working, then send the answer with a final',
|
|
361
|
+
'`channel_reply`. The ack is not your reply; the answer is. Once the answer',
|
|
362
|
+
'lands, end your turn. The `continue: true` is not optional on that ack:',
|
|
363
|
+
'without it the turn ends the instant the ack lands and the rest of your',
|
|
364
|
+
'work — the fetch, the subagent, the actual answer — is silently dropped.',
|
|
365
|
+
]),
|
|
348
366
|
'',
|
|
349
367
|
'**Backgrounded work does not end the obligation.** If you spawn a',
|
|
350
368
|
'subagent with `run_in_background: true` to answer the current inbound,',
|
|
@@ -400,15 +418,19 @@ function renderMembershipSummary(
|
|
|
400
418
|
if (membership === undefined) return null
|
|
401
419
|
|
|
402
420
|
const total = membership.humans + membership.bots
|
|
421
|
+
// Exact Discord counts are channel-scoped (filtered by who can VIEW_CHANNEL),
|
|
422
|
+
// so the count is the channel's room, not the guild. The truncated branch is
|
|
423
|
+
// history-derived recent speakers, which is not a channel-membership claim,
|
|
424
|
+
// so the caveat would mislead there.
|
|
425
|
+
const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
|
|
403
426
|
const caveat =
|
|
404
|
-
origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
|
|
405
|
-
? ' (
|
|
427
|
+
isExact && origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
|
|
428
|
+
? ' (This counts only members who can view this channel, not the whole guild.)'
|
|
406
429
|
: ''
|
|
407
|
-
const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
|
|
408
430
|
if (isExact) {
|
|
409
431
|
return `This channel has ${total} members: ${membership.humans} humans, ${membership.bots} bots.${caveat} The 10 most recent speakers are listed below.`
|
|
410
432
|
}
|
|
411
|
-
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap)
|
|
433
|
+
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap). The 10 most recent speakers are listed below.`
|
|
412
434
|
}
|
|
413
435
|
|
|
414
436
|
function renderMentionGuidance(
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
5
|
+
import type { AdapterId } from '@/channels/schema'
|
|
6
|
+
import type { ReactionRef } from '@/channels/types'
|
|
7
|
+
|
|
8
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
9
|
+
import { TOOL_RESULT_PREFIX } from './channel-reply'
|
|
10
|
+
|
|
11
|
+
export type ChannelReactOrigin = {
|
|
12
|
+
adapter: AdapterId
|
|
13
|
+
workspace: string
|
|
14
|
+
chat: string
|
|
15
|
+
thread: string | null
|
|
16
|
+
reactionRef?: ReactionRef
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CreateChannelReactToolOptions = {
|
|
20
|
+
router: ChannelRouter
|
|
21
|
+
origin: ChannelReactOrigin
|
|
22
|
+
logger?: ChannelToolLogger
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createChannelReactTool({
|
|
26
|
+
router,
|
|
27
|
+
origin,
|
|
28
|
+
logger = consoleChannelLogger,
|
|
29
|
+
}: CreateChannelReactToolOptions) {
|
|
30
|
+
return defineTool({
|
|
31
|
+
name: 'channel_react',
|
|
32
|
+
label: 'Channel React',
|
|
33
|
+
description:
|
|
34
|
+
'Add an emoji reaction to the message that triggered this turn — a lightweight acknowledgment that does not post a comment. ' +
|
|
35
|
+
'On GitHub this reacts to the triggering issue/PR/comment (e.g. :eyes: to signal "I am looking at this"). ' +
|
|
36
|
+
'Use this instead of a textual "on it" reply when a reaction is enough. Pass the bare emoji name, no colons.',
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
emoji: Type.String({
|
|
39
|
+
description: 'Bare emoji name, no surrounding colons. e.g. "eyes", "+1", "rocket", "heart".',
|
|
40
|
+
minLength: 1,
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
async execute(_toolCallId, params) {
|
|
45
|
+
const deny = (error: string) => {
|
|
46
|
+
logger.warn(formatChannelToolFailure('channel_react', error))
|
|
47
|
+
const details: { ok: boolean; error?: string } = { ok: false, error }
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_react denied: ${error}` }],
|
|
50
|
+
details,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (origin.reactionRef === undefined) return deny('this conversation has no message to react to')
|
|
55
|
+
|
|
56
|
+
const result = await router.react({
|
|
57
|
+
adapter: origin.adapter,
|
|
58
|
+
workspace: origin.workspace,
|
|
59
|
+
chat: origin.chat,
|
|
60
|
+
thread: origin.thread,
|
|
61
|
+
reactionRef: origin.reactionRef,
|
|
62
|
+
emoji: params.emoji,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (!result.ok) return deny(`${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`)
|
|
66
|
+
|
|
67
|
+
const details: { ok: boolean; error?: string } = { ok: true }
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: 'text' as const,
|
|
72
|
+
text: `${TOOL_RESULT_PREFIX}reacted with :${params.emoji}: on ${origin.adapter}:${origin.workspace}/${origin.chat}`,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
details,
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
104
|
-
'
|
|
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
|
|
|
@@ -129,6 +129,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
129
129
|
sessionId: resolvedHandle.sessionId ?? '<pending>',
|
|
130
130
|
subagentName,
|
|
131
131
|
parentSessionId,
|
|
132
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
132
133
|
startedAt,
|
|
133
134
|
status: 'running' as const,
|
|
134
135
|
abort: resolvedHandle.abort,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PermissionService } from '@/permissions'
|
|
2
|
+
|
|
3
|
+
import type { LiveSubagent, LiveSubagentRegistry } from '../live-subagents'
|
|
4
|
+
import type { SessionOrigin } from '../session-origin'
|
|
5
|
+
|
|
6
|
+
export type SubagentAccessPermission = 'subagent.output' | 'subagent.cancel'
|
|
7
|
+
|
|
8
|
+
export type SubagentAccessResult = { ok: true; live: LiveSubagent } | { ok: false; message: string }
|
|
9
|
+
|
|
10
|
+
export type AuthorizeLiveSubagentAccessArgs = {
|
|
11
|
+
permissions: PermissionService | undefined
|
|
12
|
+
origin: SessionOrigin | undefined
|
|
13
|
+
liveRegistry: LiveSubagentRegistry
|
|
14
|
+
taskId: string
|
|
15
|
+
permission: SubagentAccessPermission
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Authorizes a single subagent_output/subagent_cancel call and resolves the
|
|
19
|
+
// live entry in one place so the two tools cannot drift. Caps access to the
|
|
20
|
+
// requester's role: the caller must hold the permission AND resolve to a role
|
|
21
|
+
// at least as high as the role that spawned the subagent.
|
|
22
|
+
//
|
|
23
|
+
// The ordering closes an existence oracle: the task-independent base-permission
|
|
24
|
+
// check runs BEFORE any registry lookup, and for non-owner callers an absent
|
|
25
|
+
// task, a capped task, and a task with missing provenance all collapse to one
|
|
26
|
+
// identical denial — so a lower-role caller cannot probe which task IDs are
|
|
27
|
+
// live. Only `owner` (the trust root, which outranks every spawner) learns the
|
|
28
|
+
// truthful `Unknown task_id` for a genuine miss. The cap fails closed.
|
|
29
|
+
export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArgs): SubagentAccessResult {
|
|
30
|
+
const { permissions, origin, liveRegistry, taskId, permission } = args
|
|
31
|
+
|
|
32
|
+
if (permissions === undefined) {
|
|
33
|
+
const live = liveRegistry.get(taskId)
|
|
34
|
+
if (live === undefined) {
|
|
35
|
+
return { ok: false, message: `Unknown task_id: ${taskId}.` }
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, live }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!permissions.has(origin, permission)) {
|
|
41
|
+
return { ok: false, message: `${permission} denied: insufficient permissions` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const requesterRole = permissions.resolveRole(origin)
|
|
45
|
+
const accessAll = requesterRole === 'owner'
|
|
46
|
+
const opaqueDenial = `${permission} denied: unknown task_id or insufficient role`
|
|
47
|
+
|
|
48
|
+
const live = liveRegistry.get(taskId)
|
|
49
|
+
if (live === undefined) {
|
|
50
|
+
return { ok: false, message: accessAll ? `Unknown task_id: ${taskId}.` : opaqueDenial }
|
|
51
|
+
}
|
|
52
|
+
if (accessAll) {
|
|
53
|
+
return { ok: true, live }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spawnedByRole = live.spawnedByRole
|
|
57
|
+
if (spawnedByRole === undefined) {
|
|
58
|
+
return { ok: false, message: opaqueDenial }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cmp = permissions.compareRoleSeverity(requesterRole, spawnedByRole)
|
|
62
|
+
if (cmp === undefined || cmp < 0) {
|
|
63
|
+
return { ok: false, message: opaqueDenial }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ok: true, live }
|
|
67
|
+
}
|