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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +9 -1
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/model-overrides.ts +77 -0
  5. package/src/agent/plugin-tools.ts +53 -4
  6. package/src/agent/session-origin.ts +32 -10
  7. package/src/agent/tools/channel-react.ts +79 -0
  8. package/src/agent/tools/grant-role.ts +102 -8
  9. package/src/agent/tools/spawn-subagent.ts +1 -0
  10. package/src/agent/tools/subagent-access.ts +67 -0
  11. package/src/agent/tools/subagent-cancel.ts +11 -6
  12. package/src/agent/tools/subagent-output.ts +10 -2
  13. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  14. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  15. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  17. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  18. package/src/channels/adapters/discord-bot.ts +242 -7
  19. package/src/channels/adapters/github/inbound.ts +40 -55
  20. package/src/channels/adapters/github/index.ts +89 -18
  21. package/src/channels/adapters/github/membership.ts +4 -0
  22. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  23. package/src/channels/adapters/github/reactions.ts +142 -0
  24. package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
  25. package/src/channels/adapters/slack-bot.ts +4 -4
  26. package/src/channels/commands.ts +10 -0
  27. package/src/channels/engagement.ts +30 -2
  28. package/src/channels/github-token-bridge.ts +42 -0
  29. package/src/channels/index.ts +6 -0
  30. package/src/channels/manager.ts +6 -0
  31. package/src/channels/membership.ts +9 -0
  32. package/src/channels/router.ts +295 -42
  33. package/src/channels/types.ts +42 -0
  34. package/src/cli/inspect.ts +3 -0
  35. package/src/cli/ui.ts +6 -0
  36. package/src/commands/index.ts +54 -4
  37. package/src/init/dockerfile.ts +60 -0
  38. package/src/init/validate-api-key.ts +15 -1
  39. package/src/inspect/loop.ts +12 -1
  40. package/src/permissions/permissions.ts +24 -0
  41. package/src/plugin/context.ts +8 -0
  42. package/src/plugin/manager.ts +3 -0
  43. package/src/plugin/types.ts +6 -0
  44. package/src/run/bundled-plugins.ts +9 -0
  45. package/src/run/index.ts +4 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
package/src/cli/ui.ts CHANGED
@@ -152,6 +152,12 @@ export const SLACK_APP_MANIFEST = {
152
152
  // so a misconfigured (non-Socket-Mode) deployment fails fast rather
153
153
  // than silently routing real slash invocations to a third-party URL.
154
154
  slash_commands: [
155
+ {
156
+ command: '/help',
157
+ description: 'List available commands',
158
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
159
+ should_escape: false,
160
+ },
155
161
  {
156
162
  command: '/stop',
157
163
  description: 'Abort the current turn in this channel',
@@ -1,8 +1,27 @@
1
- export type CommandHandler<Context> = (context: Context, command: ParsedCommand) => Promise<void> | void
1
+ // Returning nothing keeps the void contract the dispatch layer then falls
2
+ // back to its static per-result-kind reply. A `reply` lets dynamic commands
3
+ // (/help) surface text the dispatcher could not have known statically.
4
+ export type CommandHandlerResult = void | { reply?: string }
5
+
6
+ export type CommandHandler<Context> = (
7
+ context: Context,
8
+ command: ParsedCommand,
9
+ ) => Promise<CommandHandlerResult> | CommandHandlerResult
10
+
11
+ // `permission` and `requiresLiveSession` are command-level policy the dispatch
12
+ // layer (the channel router) enforces. They live on the command, not the
13
+ // dispatcher, so a new command declares its own requirements in one place:
14
+ // 'session.control' + requiresLiveSession:true is the control-command default
15
+ // (/stop); 'none' + requiresLiveSession:false is the informational default
16
+ // (/help). Both are optional so plain registries (tests, TUI) need not care.
17
+ export type CommandPermission = 'none' | 'session.control'
2
18
 
3
19
  export type Command<Context> = {
4
20
  name: string
5
21
  aliases?: readonly string[]
22
+ description: string
23
+ permission?: CommandPermission
24
+ requiresLiveSession?: boolean
6
25
  handler: CommandHandler<Context>
7
26
  }
8
27
 
@@ -11,14 +30,27 @@ export type ParsedCommand = {
11
30
  args: string
12
31
  }
13
32
 
33
+ // Read-only view of a registered command, used to generate help text from the
34
+ // registry so the listing can never drift from the actual command set. Aliases
35
+ // are folded into the canonical entry rather than listed as separate commands.
36
+ export type CommandInfo = {
37
+ name: string
38
+ aliases: readonly string[]
39
+ description: string
40
+ permission: CommandPermission
41
+ requiresLiveSession: boolean
42
+ }
43
+
14
44
  export type CommandResult =
15
45
  | { kind: 'not-command' }
16
46
  | { kind: 'unknown-command'; name: string }
17
- | { kind: 'handled'; name: string }
47
+ | { kind: 'handled'; name: string; reply?: string }
18
48
 
19
49
  export type CommandRegistry<Context> = {
20
50
  parse: (text: string) => ParsedCommand | null
21
51
  has: (name: string) => boolean
52
+ get: (name: string) => CommandInfo | undefined
53
+ list: () => readonly CommandInfo[]
22
54
  execute: (text: string, context: Context) => Promise<CommandResult>
23
55
  }
24
56
 
@@ -33,16 +65,34 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
33
65
  }
34
66
  }
35
67
 
68
+ const info = (command: Command<Context>): CommandInfo => ({
69
+ name: command.name,
70
+ aliases: command.aliases ?? [],
71
+ description: command.description,
72
+ permission: command.permission ?? 'session.control',
73
+ requiresLiveSession: command.requiresLiveSession ?? true,
74
+ })
75
+
36
76
  return {
37
77
  parse: parseCommand,
38
78
  has: (name) => byName.has(name.toLowerCase()),
79
+ get: (name) => {
80
+ const command = byName.get(name.toLowerCase())
81
+ return command ? info(command) : undefined
82
+ },
83
+ // Canonical commands only, in declaration order. Aliases resolve to the
84
+ // same Command object, so de-dupe by identity to avoid duplicate rows.
85
+ list: () => commands.map(info),
39
86
  execute: async (text, context) => {
40
87
  const parsed = parseCommand(text)
41
88
  if (parsed === null) return { kind: 'not-command' }
42
89
  const command = byName.get(parsed.name)
43
90
  if (!command) return { kind: 'unknown-command', name: parsed.name }
44
- await command.handler(context, parsed)
45
- return { kind: 'handled', name: command.name }
91
+ const result = await command.handler(context, parsed)
92
+ const reply = result?.reply
93
+ return reply !== undefined
94
+ ? { kind: 'handled', name: command.name, reply }
95
+ : { kind: 'handled', name: command.name }
46
96
  },
47
97
  }
48
98
  }
@@ -50,6 +50,16 @@ export type BuildDockerfileOptions = {
50
50
  // flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
51
51
  // fails at startup. The two changes are load-bearing together — do not drop
52
52
  // one without the other.
53
+ //
54
+ // `jq` is in baseline (not behind a toggle) so it is available to the
55
+ // per-tool bwrap sandbox that wraps agent bash calls. The sandbox only
56
+ // `--ro-bind`s `/usr` + `/bin` from the container (see `src/sandbox/build.ts`),
57
+ // so a binary that is not in the base image is invisible inside the sandbox —
58
+ // there is no per-call install path. JSON munging in one-shot read-only
59
+ // pipelines (`curl ... | jq`, `cat foo.json | jq`) is a baseline expectation
60
+ // for the explorer/reviewer/scout subagents, whose prompts already advertise
61
+ // `jq` as an available pipeline tool, so it ships unconditionally rather than
62
+ // as an opt-in toggle.
53
63
  const BASELINE_APT_PACKAGES = [
54
64
  'git',
55
65
  'ca-certificates',
@@ -58,6 +68,7 @@ const BASELINE_APT_PACKAGES = [
58
68
  'iptables',
59
69
  'util-linux',
60
70
  'bubblewrap',
71
+ 'jq',
61
72
  ] as const
62
73
 
63
74
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
@@ -87,6 +98,33 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
87
98
  // the impersonation to whatever `curl_chrome` resolves to.
88
99
  export const CURL_IMPERSONATE_PROFILE = 'chrome136'
89
100
 
101
+ // yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
102
+ // prompts advertise alongside `jq` as a sanctioned one-shot read-only
103
+ // pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
104
+ // is not in the container base image is invisible inside the per-tool bwrap
105
+ // sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
106
+ // + `/bin` from the container, and there is no per-call install path. So `yq`
107
+ // ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
108
+ //
109
+ // This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
110
+ // Python jq-wrapper of the same name in Debian's `yq` apt package. The
111
+ // prompts pair `yq` with `jq` for jq-style expression pipelines, which is
112
+ // Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
113
+ // per-arch static binary (no apt package on trixie for the Go variant), so
114
+ // it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
115
+ // curl-impersonate and cloudflared rather than the apt baseline list.
116
+ //
117
+ // To bump: pick a release from https://github.com/mikefarah/yq/releases,
118
+ // then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
119
+ // the SHA-256 column from the release's `checksums` file, cross-indexed via
120
+ // `checksums_hashes_order`). Update all three constants in the same commit;
121
+ // the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
122
+ // the release tag exactly as it appears on GitHub (with `v` prefix).
123
+ export const YQ_VERSION = 'v4.53.2'
124
+ export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
125
+ export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
126
+ export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
127
+
90
128
  // cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
91
129
  // SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
92
130
  // all three constants in the same commit, and the build fails loudly at
@@ -1177,6 +1215,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1177
1215
 
1178
1216
  ${LAYER_2_5_CURL_IMPERSONATE}
1179
1217
 
1218
+ ${LAYER_2_6_YQ}
1219
+
1180
1220
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1181
1221
 
1182
1222
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1256,6 +1296,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1256
1296
 
1257
1297
  ${LAYER_2_5_CURL_IMPERSONATE}
1258
1298
 
1299
+ ${LAYER_2_6_YQ}
1300
+
1259
1301
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1260
1302
 
1261
1303
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1310,6 +1352,24 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
1310
1352
  && rm curl-impersonate.tar.gz \\
1311
1353
  && /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
1312
1354
 
1355
+ // Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
1356
+ // subagents' advertised `... | yq` pipelines resolve inside the bwrap
1357
+ // sandbox. Unconditional like the apt baseline (jq, git): a missing binary
1358
+ // is invisible to sandboxed bash, so this is not behind a toggle. Placed
1359
+ // after curl-impersonate (curl + ca-certificates from baseline guaranteed
1360
+ // present) and before agent-browser so an agent-browser bump doesn't
1361
+ // invalidate this layer. See the YQ_* constants above for the bump recipe
1362
+ // and the Go-vs-Python `yq` rationale.
1363
+ const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
1364
+ RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
1365
+ && ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
1366
+ && cd /tmp \\
1367
+ && curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
1368
+ && echo "\${ARCH_SHA} yq" | sha256sum -c - \\
1369
+ && chmod +x yq \\
1370
+ && mv yq /usr/local/bin/yq \\
1371
+ && /usr/local/bin/yq --version > /dev/null`
1372
+
1313
1373
  const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
1314
1374
  # chromium. Independent of the npm install below so it stays cached across
1315
1375
  # agent-browser version bumps.
@@ -1,3 +1,4 @@
1
+ import { effectiveBaseUrl } from '@/agent/model-overrides'
1
2
  import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
2
3
 
3
4
  const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader: 'bearer' | 'x-api-key' }>> = {
@@ -8,6 +9,19 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
8
9
  'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
9
10
  }
10
11
 
12
+ // When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
13
+ // proxy, probe THAT endpoint — validating against the public API would test the
14
+ // wrong gateway (and may reject a proxy-only credential). The path suffix is
15
+ // whatever the hardcoded default probe URL adds on top of the provider's
16
+ // default baseUrl, so providers with different version-segment conventions
17
+ // (anthropic baseUrl omits `/v1`, openai includes it) each keep their own path.
18
+ function probeUrlFor(providerId: KnownProviderId, defaultUrl: string): string {
19
+ const defaultBase = KNOWN_PROVIDERS[providerId].baseUrl
20
+ const base = effectiveBaseUrl(providerId, defaultBase)
21
+ if (base === undefined) return defaultUrl
22
+ return `${base}${defaultUrl.slice(defaultBase.length)}`
23
+ }
24
+
11
25
  export type KeyValidationResult =
12
26
  | { kind: 'ok' }
13
27
  | { kind: 'skipped'; reason: 'no-probe' | 'network-error'; detail?: string }
@@ -36,7 +50,7 @@ export async function validateApiKey(
36
50
  }
37
51
 
38
52
  try {
39
- const res = await fetchImpl(probe.url, {
53
+ const res = await fetchImpl(probeUrlFor(providerId, probe.url), {
40
54
  method: 'GET',
41
55
  headers,
42
56
  signal: AbortSignal.timeout(TIMEOUT_MS),
@@ -2,6 +2,12 @@ import { runInspect, type RunInspectOptions, type RunInspectResult } from './ind
2
2
 
3
3
  export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
4
  newEscSignal: () => AbortSignal
5
+ // Runs after every runInspect attempt settles. The caller disarms the raw-mode
6
+ // ESC listener here so the live tail releases stdin before clack re-opens the
7
+ // picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
8
+ // handler attached), and handing clack that flowing stream freezes the picker
9
+ // on SSH/Bun pseudo-TTYs.
10
+ afterEscStream?: () => void
5
11
  }
6
12
 
7
13
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
@@ -23,7 +29,12 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
23
29
  if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
24
30
  else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
25
31
 
26
- const result = await runInspect(callOpts)
32
+ let result: RunInspectResult
33
+ try {
34
+ result = await runInspect(callOpts)
35
+ } finally {
36
+ opts.afterEscStream?.()
37
+ }
27
38
  if (!result.ok) return result
28
39
  if (result.escToPicker !== true) return result
29
40
  sessionArg = undefined
@@ -9,6 +9,12 @@ export type PermissionService = {
9
9
  has(origin: SessionOrigin | undefined, permission: string): boolean
10
10
  resolveRole(origin: SessionOrigin | undefined): string
11
11
  describe(origin: SessionOrigin | undefined): { role: string; permissions: readonly string[] }
12
+ // Orders two role names on the severity tower so callers can cap an
13
+ // action to the requester's role (a guest turn must not read the output
14
+ // of a member-spawned subagent). `undefined` means an unknown role on
15
+ // either side and MUST be treated as deny, never allow — mistreating it
16
+ // as allow reopens the privilege-escalation hole this gate closes.
17
+ compareRoleSeverity(a: string, b: string): -1 | 0 | 1 | undefined
12
18
  // Rebuilds the resolved role table from the given roles config, preserving
13
19
  // the same plugin-permission set captured at construction time. Used by
14
20
  // the config reloadable so role match-rule edits (typeclaw role claim,
@@ -25,6 +31,7 @@ export type UnknownPermissionWarning = {
25
31
  export const noopPermissionService: PermissionService = {
26
32
  has: () => false,
27
33
  resolveRole: () => 'guest',
34
+ compareRoleSeverity: () => undefined,
28
35
  describe: () => ({ role: 'guest', permissions: [] }),
29
36
  replaceRoles: () => {},
30
37
  }
@@ -139,6 +146,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
139
146
  return 'guest'
140
147
  }
141
148
 
149
+ function roleSeverity(name: string): number | undefined {
150
+ if (name === 'owner') return 4
151
+ if (name === 'trusted') return 3
152
+ if (name === 'member') return 1
153
+ if (name === 'guest') return 0
154
+ if (byName.has(name)) return 2
155
+ return undefined
156
+ }
157
+
142
158
  return {
143
159
  has(origin, permission) {
144
160
  // Fail-safe floor: an undefined origin holds nothing, regardless of
@@ -156,6 +172,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
156
172
  return role.permissions.includes(permission)
157
173
  },
158
174
  resolveRole,
175
+ compareRoleSeverity(a, b) {
176
+ const aRank = roleSeverity(a)
177
+ const bRank = roleSeverity(b)
178
+ if (aRank === undefined || bRank === undefined) return undefined
179
+ if (aRank < bRank) return -1
180
+ if (aRank > bRank) return 1
181
+ return 0
182
+ },
159
183
  describe(origin) {
160
184
  const name = resolveRole(origin)
161
185
  const role = byName.get(name)
@@ -1,3 +1,4 @@
1
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
1
2
  import type { PermissionService } from '@/permissions'
2
3
 
3
4
  import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
@@ -11,10 +12,16 @@ export type CreatePluginContextOptions<TConfig> = {
11
12
  config: TConfig
12
13
  logger: PluginLogger
13
14
  permissions: PermissionService
15
+ resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
14
16
  spawnSubagent: SpawnSubagentFn
15
17
  isBooted: () => boolean
16
18
  }
17
19
 
20
+ const githubTokenUnavailable: ResolveGithubTokenForRepo = async () => ({
21
+ kind: 'unavailable',
22
+ reason: 'GitHub token resolution is not wired in this context.',
23
+ })
24
+
18
25
  export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TConfig>): PluginContext<TConfig> {
19
26
  return Object.freeze({
20
27
  name: opts.name,
@@ -23,6 +30,7 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
23
30
  config: opts.config,
24
31
  logger: opts.logger,
25
32
  permissions: opts.permissions,
33
+ github: { resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable },
26
34
  spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
27
35
  if (!opts.isBooted()) {
28
36
  throw new Error(
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
3
4
  import type { CronJob } from '@/cron'
4
5
  import {
5
6
  createPermissionService,
@@ -20,6 +21,7 @@ export type LoadPluginsOptions = {
20
21
  configsByName: Record<string, unknown>
21
22
  loadEntry?: LoadPluginEntryFn
22
23
  roles?: RolesConfig
24
+ resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
23
25
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
24
26
  // before user-declared `entries` so a config block named after a bundled
25
27
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -101,6 +103,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
101
103
  config: validatedConfig as never,
102
104
  logger,
103
105
  permissions,
106
+ resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
104
107
  spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
105
108
  isBooted: () => booted,
106
109
  })
@@ -2,6 +2,7 @@ import type { z } from 'zod'
2
2
 
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
4
  import type { SubagentShared } from '@/agent/subagents'
5
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
5
6
  import type { PermissionService } from '@/permissions'
6
7
 
7
8
  export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
@@ -273,9 +274,14 @@ export type PluginContext<TConfig = never> = {
273
274
  readonly config: TConfig
274
275
  readonly logger: PluginLogger
275
276
  readonly permissions: PermissionService
277
+ readonly github: PluginGithubServices
276
278
  spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
277
279
  }
278
280
 
281
+ export type PluginGithubServices = {
282
+ resolveTokenForRepo: ResolveGithubTokenForRepo
283
+ }
284
+
279
285
  export type PluginExports = {
280
286
  tools?: Record<string, Tool<any>>
281
287
  subagents?: Record<string, Subagent<any>>
@@ -1,6 +1,7 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
3
  import explorerPlugin from '@/bundled-plugins/explorer'
4
+ import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
4
5
  import guardPlugin from '@/bundled-plugins/guard'
5
6
  import memoryPlugin from '@/bundled-plugins/memory'
6
7
  import operatorPlugin from '@/bundled-plugins/operator'
@@ -28,6 +29,13 @@ import type { ResolvedPlugin } from '@/plugin'
28
29
  // Reversing this order would make guard advise on the full oversized payload
29
30
  // and then tool-result-cap would clobber the advice text along with the rest.
30
31
  //
32
+ // `github-cli-auth` is registered AFTER `security` so security's `tool.before`
33
+ // runs its exfil/secret scanners on the bash command first. github-cli-auth
34
+ // injects the minted token via an env overlay (TYPECLAW_INTERNAL_BASH_ENV), not
35
+ // by rewriting the command string, so the token never enters argv or logs — but
36
+ // ordering security first still matters so a blocked command never reaches the
37
+ // mint path at all.
38
+ //
31
39
  // `memory` is registered before `backup` so memory's dreaming commits always
32
40
  // land in the same git index window before backup's commit-and-push cycle.
33
41
  // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
@@ -37,6 +45,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
37
45
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
38
46
  { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
39
47
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
48
+ { name: 'github-cli-auth', version: undefined, source: '<bundled>', defined: githubCliAuthPlugin },
40
49
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
41
50
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
42
51
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
package/src/run/index.ts CHANGED
@@ -19,6 +19,7 @@ import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
19
19
  import {
20
20
  createChannelManager,
21
21
  createChannelsReloadable,
22
+ createGithubTokenBridge,
22
23
  createSubagentCompletionBridge,
23
24
  type ChannelManager,
24
25
  type SubagentCompletionBridge,
@@ -138,11 +139,13 @@ export async function startAgent({
138
139
 
139
140
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
140
141
  const cwdConfig = loadConfigSync(cwd)
142
+ const githubTokenBridge = createGithubTokenBridge()
141
143
  const pluginsLoaded = await loadPlugins({
142
144
  entries: cwdConfig.plugins,
143
145
  agentDir: cwd,
144
146
  configsByName: pluginConfigsByName,
145
147
  bundled: BUNDLED_PLUGINS,
148
+ resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
146
149
  ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
147
150
  })
148
151
 
@@ -255,6 +258,7 @@ export async function startAgent({
255
258
  }),
256
259
  permissions: pluginsLoaded.permissions,
257
260
  claimHandler: claimController.claimHandler,
261
+ githubTokenBridge,
258
262
  stream,
259
263
  })
260
264