typeclaw 0.33.0 → 0.34.1

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 (64) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/github-cli-auth/git-askpass.ts +65 -0
  15. package/src/bundled-plugins/github-cli-auth/git-command.ts +492 -0
  16. package/src/bundled-plugins/github-cli-auth/index.ts +97 -36
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  18. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  19. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  20. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
  21. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  22. package/src/channels/adapters/line-classify.ts +80 -0
  23. package/src/channels/adapters/line-format.ts +11 -0
  24. package/src/channels/adapters/line.ts +350 -0
  25. package/src/channels/engagement.ts +4 -2
  26. package/src/channels/manager.ts +65 -6
  27. package/src/channels/router.ts +186 -41
  28. package/src/channels/schema.ts +6 -1
  29. package/src/cli/channel.ts +112 -1
  30. package/src/cli/cron.ts +22 -4
  31. package/src/cli/oauth-callbacks.ts +5 -4
  32. package/src/config/providers.ts +62 -0
  33. package/src/cron/consumer.ts +33 -0
  34. package/src/cron/count-state.ts +208 -0
  35. package/src/cron/index.ts +4 -17
  36. package/src/cron/list.ts +24 -6
  37. package/src/cron/scheduler.ts +84 -9
  38. package/src/cron/schema.ts +100 -13
  39. package/src/doctor/channel-checks.ts +28 -0
  40. package/src/hostd/daemon.ts +14 -6
  41. package/src/hostd/protocol.ts +6 -2
  42. package/src/init/gitignore.ts +1 -1
  43. package/src/init/index.ts +36 -3
  44. package/src/init/line-auth.ts +98 -0
  45. package/src/init/models-dev.ts +1 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +2 -0
  48. package/src/inspect/label.ts +1 -0
  49. package/src/permissions/match-rule.ts +28 -12
  50. package/src/permissions/resolve.ts +8 -1
  51. package/src/role-claim/match-rule.ts +5 -1
  52. package/src/run/index.ts +41 -4
  53. package/src/secrets/line-store.ts +112 -0
  54. package/src/secrets/oauth-xai.ts +1 -1
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/server/index.ts +17 -4
  57. package/src/shared/protocol.ts +4 -1
  58. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  59. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  60. package/src/skills/typeclaw-config/SKILL.md +54 -184
  61. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  62. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  63. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  64. package/typeclaw.schema.json +167 -3
@@ -0,0 +1,492 @@
1
+ // Plain-`git` analog of analyzeGhCommand. `gh` names its repo in argv (`-R`);
2
+ // `git` only implies it via a remote, so this RESOLVES the target repo:
3
+ // explicit github.com URL -> remote name via `git remote get-url` -> the
4
+ // branch.<cur>.pushRemote/remote.pushDefault/origin fallback chain. The slug
5
+ // feeds the same per-repo mint the `gh` path uses; the token is injected via
6
+ // GIT_ASKPASS env, never into the command string.
7
+
8
+ export type GitCommandDecision =
9
+ | { kind: 'pass-through' }
10
+ | { kind: 'block'; reason: string }
11
+ // rewrittenCommand replaces the executed command: `cd <dir> && git …` becomes
12
+ // `git -C <dir> …` so the token-bearing command stays a single bare `git`
13
+ // (no sibling process inherits the askpass env).
14
+ | { kind: 'inject'; repoSlug: string; rewrittenCommand?: string }
15
+
16
+ // Returns null when the remote/config is absent or git fails — the analyzer
17
+ // then passes through so git fails honestly rather than us guessing a repo.
18
+ // forPush selects the PUSH url (`git remote get-url --push`), which differs from
19
+ // the fetch url when a remote sets `pushurl`; minting against the fetch url for a
20
+ // push would scope the token to the wrong repo/owner.
21
+ export type GitRemoteResolver = (cwd: string, remote: string, forPush: boolean) => Promise<string | null>
22
+ export type GitConfigResolver = (cwd: string, key: string) => Promise<string | null>
23
+ export type GitBranchResolver = (cwd: string) => Promise<string | null>
24
+
25
+ export type GitResolvers = {
26
+ resolveRemoteUrl: GitRemoteResolver
27
+ resolveConfig: GitConfigResolver
28
+ resolveCurrentBranch: GitBranchResolver
29
+ }
30
+
31
+ async function runGit(cwd: string, args: string[]): Promise<string | null> {
32
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
33
+ if (!bun) return null
34
+ try {
35
+ const proc = bun.spawn({
36
+ cmd: ['git', '-C', cwd, ...args],
37
+ stdout: 'pipe',
38
+ stderr: 'ignore',
39
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
40
+ })
41
+ const exitCode = await proc.exited
42
+ if (exitCode !== 0) return null
43
+ const out = (await new Response(proc.stdout).text()).trim()
44
+ return out === '' ? null : out
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ export const defaultGitResolvers: GitResolvers = {
51
+ resolveRemoteUrl: (cwd, remote, forPush) =>
52
+ runGit(cwd, forPush ? ['remote', 'get-url', '--push', remote] : ['remote', 'get-url', remote]),
53
+ resolveConfig: (cwd, key) => runGit(cwd, ['config', '--get', key]),
54
+ resolveCurrentBranch: (cwd) => runGit(cwd, ['symbolic-ref', '--short', 'HEAD']),
55
+ }
56
+
57
+ const REMOTE_SUBCOMMANDS = new Set(['push', 'fetch', 'pull', 'clone', 'ls-remote'])
58
+
59
+ const MULTI_OWNER_REASON =
60
+ 'This git command targets repos under more than one owner; a single minted ' +
61
+ 'GitHub App token cannot authenticate all of them. Split it into separate ' +
62
+ 'commands, one owner each.'
63
+
64
+ const COMPOSITION_REASON =
65
+ 'A repo-targeting `git` command receives a minted GitHub App token via ' +
66
+ 'GIT_ASKPASS in its process environment, so it must run as a single bare ' +
67
+ '`git` command — no `;`, `&&`, `||`, `&`, newlines, pipes, redirections, ' +
68
+ 'command/parameter substitution, or subshells (any sibling process would ' +
69
+ 'inherit the token). The one accepted prefix is `cd <simple-path> && git …`, ' +
70
+ 'which is rewritten to `git -C <path> …`.'
71
+
72
+ const DANGEROUS_CONFIG_REASON =
73
+ 'A repo-targeting `git` command that would receive a minted GitHub App token ' +
74
+ 'cannot carry a leading environment assignment or `-c`/`--config-env`/' +
75
+ '`--git-dir`/`--work-tree`/`--namespace`/`--exec-path`: those can redirect ' +
76
+ "git's authentication or destination away from the resolved repo (e.g. " +
77
+ '`-c url.https://evil/.insteadOf=…` or `-c core.askPass=…`), which would leak ' +
78
+ 'the token. Remove them and re-run, or run without the minted token.'
79
+
80
+ const FETCH_ALL_REASON =
81
+ '`git fetch --all` / `git pull --all` contacts every configured remote, which ' +
82
+ 'cannot be enumerated safely here — a minted token scoped to one remote could ' +
83
+ 'be sent to another. Fetch a specific remote instead.'
84
+
85
+ // OUTSIDE single quotes these spawn a sibling process (which would inherit the
86
+ // askpass token) or expand shell state. `$`/backtick stay active inside double
87
+ // quotes too, so they are screened separately. Mirrors gh-command.ts.
88
+ const SHELL_ACTIVE_METACHARS = new Set(['|', ';', '&', '\n', '\r', '(', ')', '{', '}', '<', '>', '`', '$'])
89
+
90
+ export async function analyzeGitCommand(
91
+ command: string,
92
+ options: { cwd: string; resolvers: GitResolvers },
93
+ ): Promise<GitCommandDecision> {
94
+ const stripped = stripSafeCdPrefix(command)
95
+ if (stripped.unsafe) return { kind: 'pass-through' }
96
+ const segment = extractSingleGitInvocation(stripped.rest)
97
+ if (segment === null) return { kind: 'pass-through' }
98
+
99
+ const args = segment.args
100
+ const subcommand = findSubcommand(args)
101
+ if (subcommand === undefined || !REMOTE_SUBCOMMANDS.has(subcommand)) return { kind: 'pass-through' }
102
+
103
+ // We are about to put a live token in this process's env. Reject any command
104
+ // that could redirect git's auth/destination away from the resolved repo:
105
+ // user `-c` (url.*.insteadOf, core.askPass, credential.*, http.*), a leading
106
+ // env assignment (`GIT_ASKPASS=… git …`), or a different git-dir/work-tree.
107
+ if (segment.hasLeadingEnvAssignment || hasDangerousGitConfig(args)) {
108
+ return { kind: 'block', reason: DANGEROUS_CONFIG_REASON }
109
+ }
110
+ // `fetch/pull --all` contacts every configured remote; we cannot enumerate
111
+ // them here, so we could mint for one and leak the token to another. Block.
112
+ if ((subcommand === 'fetch' || subcommand === 'pull') && args.includes('--all')) {
113
+ return { kind: 'block', reason: FETCH_ALL_REASON }
114
+ }
115
+
116
+ const dashCDir = extractDashCDir(args)
117
+ const effectiveCwd = resolveCwd(resolveCwd(options.cwd, stripped.cdDir), dashCDir)
118
+
119
+ // A resolver that throws is treated as "couldn't resolve" → pass-through, so a
120
+ // git/subprocess failure never crashes the hook or guesses a repo.
121
+ let slugs: string[]
122
+ try {
123
+ slugs = await resolveRepoSlugs(subcommand, args, effectiveCwd, options.resolvers)
124
+ } catch {
125
+ return { kind: 'pass-through' }
126
+ }
127
+ if (slugs.length === 0) return { kind: 'pass-through' }
128
+
129
+ const owners = new Set(slugs.map((s) => s.split('/')[0]))
130
+ if (owners.size > 1) return { kind: 'block', reason: MULTI_OWNER_REASON }
131
+
132
+ const repoSlug = slugs[0] as string
133
+
134
+ // Injecting the askpass token into env means any sibling process inherits it,
135
+ // so a token-bearing command must be a single bare `git`. A `cd … && git …`
136
+ // is allowed only by rewriting away the `&&` into `git -C …` — but not when the
137
+ // git part already has its own `-C` (the rewrite would stack two and change cwd).
138
+ if (stripped.cdDir !== null) {
139
+ if (containsShellActiveMetachar(stripped.rest) || dashCDir !== null) {
140
+ return { kind: 'block', reason: COMPOSITION_REASON }
141
+ }
142
+ return { kind: 'inject', repoSlug, rewrittenCommand: rewriteCdToDashC(effectiveCwd, stripped.rest) }
143
+ }
144
+ if (containsShellActiveMetachar(command)) return { kind: 'block', reason: COMPOSITION_REASON }
145
+ return { kind: 'inject', repoSlug }
146
+ }
147
+
148
+ // Global flags that can redirect git's auth or destination. `-c`/`--config-env`
149
+ // inject arbitrary config (url.insteadOf, core.askPass, credential.*, http.*);
150
+ // `--git-dir`/`--work-tree`/`--namespace`/`--exec-path` point git at a different
151
+ // repo or helper than the one we resolved. Any of these on a token-bearing
152
+ // command means we cannot vouch for where the token goes — block.
153
+ function hasDangerousGitConfig(args: readonly string[]): boolean {
154
+ for (const arg of args) {
155
+ // git accepts both `--config-env <name>=<envvar>` and the inline
156
+ // `--config-env=<name>=<envvar>`; both must be caught. (`-c` has no inline
157
+ // `-c=…` form — git always takes the next token.)
158
+ if (arg === '-c' || arg === '--config-env' || arg.startsWith('--config-env=')) return true
159
+ if (arg === '--git-dir' || arg.startsWith('--git-dir=')) return true
160
+ if (arg === '--work-tree' || arg.startsWith('--work-tree=')) return true
161
+ if (arg === '--namespace' || arg.startsWith('--namespace=')) return true
162
+ if (arg === '--exec-path' || arg.startsWith('--exec-path=')) return true
163
+ }
164
+ return false
165
+ }
166
+
167
+ async function resolveRepoSlugs(
168
+ subcommand: string,
169
+ args: readonly string[],
170
+ cwd: string,
171
+ resolvers: GitResolvers,
172
+ ): Promise<string[]> {
173
+ const explicitUrl = extractExplicitUrl(subcommand, args)
174
+ if (explicitUrl !== null) {
175
+ const slug = parseGithubRepoFromGitUrl(explicitUrl)
176
+ return slug === null ? [] : [slug]
177
+ }
178
+
179
+ // clone needs an explicit URL; it has no configured-remote fallback.
180
+ if (subcommand === 'clone') return []
181
+
182
+ // Only `push` consults the push url; fetch/pull/ls-remote use the fetch url.
183
+ const forPush = subcommand === 'push'
184
+
185
+ // EVERY named remote must be resolved, not just the first: `git fetch
186
+ // --multiple origin upstream` touches both, and feeding only one to the
187
+ // multi-owner guard would let a second-owner remote slip past and still mint.
188
+ const remoteNames = extractRemoteNames(args)
189
+ // The branch.pushRemote/pushDefault/origin chain is a PUSH default; applying it
190
+ // to a bare `fetch`/`pull`/`ls-remote` could mint for the wrong (push) remote,
191
+ // so only push falls back. Other subcommands with no explicit remote pass through.
192
+ const remotes =
193
+ remoteNames.length > 0 ? remoteNames : subcommand === 'push' ? [await resolveDefaultPushRemote(cwd, resolvers)] : []
194
+ if (remotes.length === 0) return []
195
+
196
+ const slugs: string[] = []
197
+ for (const remote of remotes) {
198
+ if (remote === null) continue
199
+ const url = looksLikeUrl(remote) ? remote : await resolvers.resolveRemoteUrl(cwd, remote, forPush)
200
+ if (url === null) continue
201
+ const slug = parseGithubRepoFromGitUrl(url)
202
+ if (slug !== null) slugs.push(slug)
203
+ }
204
+ return slugs
205
+ }
206
+
207
+ // Mirrors git's own push-remote resolution order for a bare `git push`.
208
+ async function resolveDefaultPushRemote(cwd: string, resolvers: GitResolvers): Promise<string | null> {
209
+ const branch = await resolvers.resolveCurrentBranch(cwd)
210
+ if (branch !== null && branch !== '') {
211
+ const perBranch = await resolvers.resolveConfig(cwd, `branch.${branch}.pushRemote`)
212
+ if (perBranch !== null && perBranch !== '') return perBranch
213
+ }
214
+ const pushDefault = await resolvers.resolveConfig(cwd, 'remote.pushDefault')
215
+ if (pushDefault !== null && pushDefault !== '') return pushDefault
216
+ return 'origin'
217
+ }
218
+
219
+ const HTTPS_GITHUB_RE = /^https:\/\/github\.com\/([^/\s:@]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
220
+ const SCP_GITHUB_RE = /^git@github\.com:([^/\s:?#]+)\/([^/\s?#]+?)(?:\.git)?$/i
221
+ const SSH_GITHUB_RE = /^ssh:\/\/git@github\.com(?::\d+)?\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
222
+
223
+ // Parses a github.com remote URL into an `owner/name` slug. Returns null for
224
+ // non-github.com hosts, credential-bearing https URLs (https://tok@github.com/…
225
+ // — we never reuse an embedded credential), local paths, or malformed input.
226
+ export function parseGithubRepoFromGitUrl(raw: string): string | null {
227
+ const url = raw.trim()
228
+ for (const re of [HTTPS_GITHUB_RE, SCP_GITHUB_RE, SSH_GITHUB_RE]) {
229
+ const m = url.match(re)
230
+ if (m === null) continue
231
+ const owner = m[1]
232
+ const name = m[2]
233
+ if (owner === undefined || name === undefined || owner === '' || name === '') return null
234
+ return `${owner}/${name}`
235
+ }
236
+ return null
237
+ }
238
+
239
+ function looksLikeUrl(token: string): boolean {
240
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token)) return true
241
+ if (/^[^@\s]+@[^:\s]+:/.test(token)) return true
242
+ return false
243
+ }
244
+
245
+ // Flags that consume the FOLLOWING token, so a positional after them (the
246
+ // subcommand or a remote/URL) is not mistaken for the flag's value. Includes the
247
+ // dangerous-config flags in their separate-arg form (`--config-env <v>`) so the
248
+ // real subcommand is still located and hasDangerousGitConfig can fire.
249
+ const GIT_VALUE_FLAGS = new Set([
250
+ '-C',
251
+ '-c',
252
+ '--config-env',
253
+ '--git-dir',
254
+ '--work-tree',
255
+ '--namespace',
256
+ '--exec-path',
257
+ '-o',
258
+ '--origin',
259
+ '-b',
260
+ '--branch',
261
+ '-u',
262
+ ])
263
+
264
+ function extractExplicitUrl(subcommand: string, args: readonly string[]): string | null {
265
+ // `git push --repo <url>` / `--repo=<url>`
266
+ for (let i = 0; i < args.length; i++) {
267
+ const arg = args[i] as string
268
+ if (arg === '--repo' || arg === '--repository') {
269
+ const v = args[i + 1]
270
+ if (v !== undefined && looksLikeUrl(v)) return v
271
+ }
272
+ if (arg.startsWith('--repo=')) return arg.slice('--repo='.length)
273
+ if (arg.startsWith('--repository=')) return arg.slice('--repository='.length)
274
+ }
275
+ // First positional after the subcommand that looks like a URL.
276
+ for (const pos of positionalsAfterSubcommand(subcommand, args)) {
277
+ if (looksLikeUrl(pos)) return pos
278
+ }
279
+ return null
280
+ }
281
+
282
+ // The git subcommand is the first positional that is NOT consumed by a global
283
+ // value-flag (`git -C <dir> push`, `git -c k=v push`). A naive first-non-flag
284
+ // scan would pick the flag's value (e.g. `<dir>`) as the subcommand.
285
+ function findSubcommand(args: readonly string[]): string | undefined {
286
+ for (let i = 0; i < args.length; i++) {
287
+ const arg = args[i] as string
288
+ if (arg.startsWith('-')) {
289
+ if (!arg.includes('=') && GIT_VALUE_FLAGS.has(arg)) i += 1
290
+ continue
291
+ }
292
+ return arg
293
+ }
294
+ return undefined
295
+ }
296
+
297
+ // The remote name(s) a command targets. Normally just the first positional —
298
+ // later positionals are refspecs (`git push origin main` -> remote `origin`,
299
+ // refspec `main`). With `--multiple` (fetch), EVERY positional is a remote, so
300
+ // all are returned to feed the multi-owner guard. URL positionals are excluded
301
+ // here; resolveRepoSlugs handles a bare-URL target directly.
302
+ function extractRemoteNames(args: readonly string[]): string[] {
303
+ const sub = findSubcommand(args)
304
+ if (sub === undefined) return []
305
+ const positionals = positionalsAfterSubcommand(sub, args).filter((p) => !looksLikeUrl(p))
306
+ if (positionals.length === 0) return []
307
+ if (args.includes('--multiple')) return positionals
308
+ return [positionals[0] as string]
309
+ }
310
+
311
+ function positionalsAfterSubcommand(subcommand: string, args: readonly string[]): string[] {
312
+ const out: string[] = []
313
+ let seenSub = false
314
+ for (let i = 0; i < args.length; i++) {
315
+ const arg = args[i] as string
316
+ if (arg.startsWith('-')) {
317
+ if (!arg.includes('=') && GIT_VALUE_FLAGS.has(arg)) i += 1
318
+ continue
319
+ }
320
+ if (!seenSub) {
321
+ if (arg === subcommand) seenSub = true
322
+ continue
323
+ }
324
+ out.push(arg)
325
+ }
326
+ return out
327
+ }
328
+
329
+ function extractDashCDir(args: readonly string[]): string | null {
330
+ for (let i = 0; i < args.length; i++) {
331
+ const arg = args[i]
332
+ if (arg === '-C') {
333
+ const v = args[i + 1]
334
+ if (v !== undefined) return stripQuotes(v)
335
+ }
336
+ }
337
+ return null
338
+ }
339
+
340
+ type GitInvocation = { args: string[]; hasLeadingEnvAssignment: boolean }
341
+
342
+ // Null unless there is exactly one `git` at command position. Composition is NOT
343
+ // rejected here — it is screened later against the original command so the block
344
+ // reason stays accurate. hasLeadingEnvAssignment flags `FOO=bar git …`: such an
345
+ // assignment lands in git's env (where a `GIT_ASKPASS=…` override would receive
346
+ // the token), so the caller blocks it for token-bearing commands.
347
+ function extractSingleGitInvocation(command: string): GitInvocation | null {
348
+ const tokens = tokenize(command)
349
+ const gitStarts: number[] = []
350
+ for (let i = 0; i < tokens.length; i++) {
351
+ if (tokens[i] !== 'git') continue
352
+ if (i === 0 || isCommandBoundaryBefore(tokens, i)) gitStarts.push(i)
353
+ }
354
+ if (gitStarts.length !== 1) return null
355
+ const start = gitStarts[0] as number
356
+ const hasLeadingEnvAssignment = start > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start - 1] ?? '')
357
+ const args = tokens.slice(start + 1).filter((t) => t !== '\n' && t !== ';' && t !== '|' && t !== '&')
358
+ return { args, hasLeadingEnvAssignment }
359
+ }
360
+
361
+ function isCommandBoundaryBefore(tokens: readonly string[], index: number): boolean {
362
+ let cursor = index - 1
363
+ while (cursor >= 0) {
364
+ const prev = tokens[cursor]
365
+ if (prev === undefined) return false
366
+ if (prev === '&&' || prev === '||' || prev === '|' || prev === ';' || prev === '\n') return true
367
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(prev)) {
368
+ cursor -= 1
369
+ continue
370
+ }
371
+ return false
372
+ }
373
+ return true
374
+ }
375
+
376
+ function containsShellActiveMetachar(command: string): boolean {
377
+ let quote: '"' | "'" | null = null
378
+ for (let i = 0; i < command.length; i++) {
379
+ const ch = command[i] as string
380
+ if (quote === "'") {
381
+ if (ch === "'") quote = null
382
+ continue
383
+ }
384
+ if (quote === '"') {
385
+ if (ch === '$' || ch === '`') return true
386
+ if (ch === '"') quote = null
387
+ continue
388
+ }
389
+ if (ch === "'" || ch === '"') {
390
+ quote = ch
391
+ continue
392
+ }
393
+ if (SHELL_ACTIVE_METACHARS.has(ch)) return true
394
+ }
395
+ return false
396
+ }
397
+
398
+ // unsafe=true when the command LOOKS like a `cd … && git …` but the cd target is
399
+ // not a plain path we can faithfully turn into `git -C` (shell `~`/`-` expansion,
400
+ // metachars). The caller then passes through rather than rewrite wrong cwd.
401
+ type StrippedCd = { cdDir: string | null; rest: string; unsafe: boolean }
402
+
403
+ // Accepts ONLY `cd <simple-path> && git …`. <simple-path> must be a single
404
+ // token (optionally quoted) free of metachars and of a literal single quote
405
+ // (rewriteCdToDashC single-quotes it), and not a shell-expanded `~`/`-`. A plain
406
+ // command (no `cd` prefix) returns cdDir=null, unsafe=false.
407
+ function stripSafeCdPrefix(command: string): StrippedCd {
408
+ const m = command.match(/^\s*cd\s+("[^"]*"|'[^']*'|[^\s'"]+)\s+&&\s+(git\b[\s\S]*)$/)
409
+ if (m === null) return { cdDir: null, rest: command, unsafe: false }
410
+ const rawDir = m[1] as string
411
+ const rest = m[2] as string
412
+ const dir = stripQuotes(rawDir)
413
+ const shellExpands = dir === '-' || dir === '~' || dir.startsWith('~/')
414
+ if (dir.includes('$') || dir.includes('`') || dir.includes("'") || /[;|&<>()]/.test(dir) || shellExpands) {
415
+ return { cdDir: null, rest: command, unsafe: true }
416
+ }
417
+ return { cdDir: dir, rest, unsafe: false }
418
+ }
419
+
420
+ function rewriteCdToDashC(dir: string, gitCommand: string): string {
421
+ return gitCommand.replace(/^git\b/, `git -C '${dir}'`)
422
+ }
423
+
424
+ function resolveCwd(base: string, dir: string | null): string {
425
+ if (dir === null || dir === '') return base
426
+ if (dir.startsWith('/')) return dir
427
+ return `${base.replace(/\/$/, '')}/${dir}`
428
+ }
429
+
430
+ function stripQuotes(token: string): string {
431
+ if (token.length < 2) return token
432
+ const first = token[0]
433
+ const last = token[token.length - 1]
434
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return token.slice(1, -1)
435
+ return token
436
+ }
437
+
438
+ // Quote-aware; emits shell control operators as standalone tokens so
439
+ // command-boundary detection works. Mirrors gh-command.ts's tokenize.
440
+ function tokenize(command: string): string[] {
441
+ const tokens: string[] = []
442
+ let current = ''
443
+ let quote: '"' | "'" | null = null
444
+ let hasContent = false
445
+
446
+ const flush = (): void => {
447
+ if (hasContent) {
448
+ tokens.push(current)
449
+ current = ''
450
+ hasContent = false
451
+ }
452
+ }
453
+
454
+ for (let i = 0; i < command.length; i++) {
455
+ const ch = command[i]
456
+ if (ch === undefined) continue
457
+ if (quote !== null) {
458
+ if (ch === quote) quote = null
459
+ else current += ch
460
+ continue
461
+ }
462
+ if (ch === '"' || ch === "'") {
463
+ quote = ch
464
+ hasContent = true
465
+ continue
466
+ }
467
+ if (ch === ' ' || ch === '\t') {
468
+ flush()
469
+ continue
470
+ }
471
+ if (ch === '\n') {
472
+ flush()
473
+ tokens.push('\n')
474
+ continue
475
+ }
476
+ if (ch === ';' || ch === '|' || ch === '&') {
477
+ flush()
478
+ const next = command[i + 1]
479
+ if ((ch === '|' && next === '|') || (ch === '&' && next === '&')) {
480
+ tokens.push(ch + ch)
481
+ i += 1
482
+ } else {
483
+ tokens.push(ch)
484
+ }
485
+ continue
486
+ }
487
+ current += ch
488
+ hasContent = true
489
+ }
490
+ flush()
491
+ return tokens
492
+ }
@@ -4,6 +4,8 @@ import { definePlugin } from '@/plugin'
4
4
  import { createApproveIdempotencyGuard } from './approve-idempotency'
5
5
  import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
6
6
  import { analyzeGhCommand } from './gh-command'
7
+ import { ensureGitAskPassHelper } from './git-askpass'
8
+ import { analyzeGitCommand, defaultGitResolvers } from './git-command'
7
9
  import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
8
10
  import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
9
11
  import { classifyGhToken } from './token-class'
@@ -19,48 +21,107 @@ export default definePlugin({
19
21
  resolveEffectiveApproval: createGithubEffectiveApprovalResolver({ resolveToken }),
20
22
  resolveHeadSha: createGithubHeadShaResolver({ resolveToken }),
21
23
  })
24
+
25
+ type HookResult = void | { block: true; reason: string }
26
+
27
+ // 'fall-through' means "not a repo-targeting gh command" so the caller can
28
+ // try the git path on the same command (e.g. `git ... # gh` substrings).
29
+ const handleGhCommand = async (params: {
30
+ event: { callId: string; args: Record<string, unknown> }
31
+ command: string
32
+ }): Promise<HookResult | 'fall-through'> => {
33
+ const { event, command } = params
34
+ const review = await noteReviewCommand({ callId: event.callId, command })
35
+ if (review.detected !== null) {
36
+ const block = await verdictGuard.guard({
37
+ callId: event.callId,
38
+ workspace: review.detected.workspace,
39
+ prNumber: review.detected.prNumber,
40
+ verdict: review.detected.verdict,
41
+ })
42
+ if (block !== null) return block
43
+ }
44
+ if (review.dump !== null) return review.dump
45
+
46
+ const decision = analyzeGhCommand(command)
47
+ if (decision.kind === 'pass-through') return 'fall-through'
48
+
49
+ const tokenClass = classifyGhToken(process.env.GH_TOKEN)
50
+ // Classic PATs reach every owner; nothing to inject or enforce.
51
+ if (tokenClass === 'cross-owner') return
52
+
53
+ if (decision.kind === 'block') return { block: true, reason: decision.reason }
54
+
55
+ // Fine-grained PATs are single-owner but cannot be re-minted per repo;
56
+ // the seeded GH_TOKEN is the only token we have. Leave it in place so
57
+ // `gh` fails honestly if the named repo is under a different owner.
58
+ if (tokenClass === 'fine-grained-pat') return
59
+
60
+ const result = await resolveTokenForRepo(decision.repoSlug)
61
+ if (result.kind === 'unavailable') return { block: true, reason: result.reason }
62
+ // Inject via the internal env overlay (delivered to the spawn / bwrap
63
+ // --setenv by the bash wrapper) so the token never enters the command
64
+ // string, where it could leak through logs or later hooks.
65
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
66
+ // graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
67
+ // run the command with the flag stripped (token still rides in env).
68
+ if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
69
+ return
70
+ }
71
+
72
+ const handleGitCommand = async (params: {
73
+ event: { args: Record<string, unknown> }
74
+ command: string
75
+ agentDir: string
76
+ }): Promise<HookResult> => {
77
+ const { event, command, agentDir } = params
78
+ // Only App auth re-mints per repo. Classic/fine-grained PATs and absent
79
+ // tokens are left untouched, exactly as the gh path treats them.
80
+ if (classifyGhToken(process.env.GH_TOKEN) !== 'app') return
81
+
82
+ const decision = await analyzeGitCommand(command, { cwd: agentDir, resolvers: defaultGitResolvers })
83
+ if (decision.kind === 'pass-through') return
84
+ if (decision.kind === 'block') return { block: true, reason: decision.reason }
85
+
86
+ const result = await resolveTokenForRepo(decision.repoSlug)
87
+ if (result.kind === 'unavailable') return { block: true, reason: result.reason }
88
+
89
+ const askpass = await ensureGitAskPassHelper()
90
+ const existing = event.args[TYPECLAW_INTERNAL_BASH_ENV]
91
+ const overlay = existing !== null && typeof existing === 'object' ? (existing as Record<string, string>) : {}
92
+ // Token rides in TYPECLAW_GIT_TOKEN (read by the askpass helper), never in
93
+ // argv/config. insteadOf rewrites SSH/scp remotes to https so the helper's
94
+ // credential applies; GIT_TERMINAL_PROMPT=0 fails fast instead of hanging.
95
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
96
+ ...overlay,
97
+ GIT_ASKPASS: askpass,
98
+ TYPECLAW_GIT_TOKEN: result.token,
99
+ GIT_TERMINAL_PROMPT: '0',
100
+ GIT_CONFIG_COUNT: '2',
101
+ GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf',
102
+ GIT_CONFIG_VALUE_0: 'git@github.com:',
103
+ GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf',
104
+ GIT_CONFIG_VALUE_1: 'ssh://git@github.com/',
105
+ }
106
+ if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
107
+ return
108
+ }
109
+
22
110
  return {
23
111
  hooks: {
24
112
  'tool.before': async (event) => {
25
113
  if (event.tool !== 'bash') return
26
114
  const command = event.args.command
27
- if (typeof command !== 'string' || !command.includes('gh')) return
28
-
29
- const review = await noteReviewCommand({ callId: event.callId, command })
30
- if (review.detected !== null) {
31
- const block = await verdictGuard.guard({
32
- callId: event.callId,
33
- workspace: review.detected.workspace,
34
- prNumber: review.detected.prNumber,
35
- verdict: review.detected.verdict,
36
- })
37
- if (block !== null) return block
115
+ if (typeof command !== 'string') return
116
+
117
+ if (command.includes('gh')) {
118
+ const ghResult = await handleGhCommand({ event, command })
119
+ if (ghResult !== 'fall-through') return ghResult
120
+ }
121
+
122
+ if (command.includes('git')) {
123
+ return await handleGitCommand({ event, command, agentDir: ctx.agentDir })
38
124
  }
39
- if (review.dump !== null) return review.dump
40
-
41
- const decision = analyzeGhCommand(command)
42
- if (decision.kind === 'pass-through') return
43
-
44
- const tokenClass = classifyGhToken(process.env.GH_TOKEN)
45
- // Classic PATs reach every owner; nothing to inject or enforce.
46
- if (tokenClass === 'cross-owner') return
47
-
48
- if (decision.kind === 'block') return { block: true, reason: decision.reason }
49
-
50
- // Fine-grained PATs are single-owner but cannot be re-minted per repo;
51
- // the seeded GH_TOKEN is the only token we have. Leave it in place so
52
- // `gh` fails honestly if the named repo is under a different owner.
53
- if (tokenClass === 'fine-grained-pat') return
54
-
55
- const result = await resolveTokenForRepo(decision.repoSlug)
56
- if (result.kind === 'unavailable') return { block: true, reason: result.reason }
57
- // Inject via the internal env overlay (delivered to the spawn / bwrap
58
- // --setenv by the bash wrapper) so the token never enters the command
59
- // string, where it could leak through logs or later hooks.
60
- event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
61
- // graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
62
- // run the command with the flag stripped (token still rides in env).
63
- if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
64
125
  return
65
126
  },
66
127
  'tool.after': async (event) => {
@@ -71,7 +71,7 @@ function validateManagedContent(file: ManagedFile, content: string): { ok: true
71
71
  const result = parseConfigJson(content, { migrate: false })
72
72
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
73
73
  }
74
- const result = parseCronJson(content)
74
+ const result = parseCronJson(content, { mode: 'edit' })
75
75
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
76
76
  }
77
77
 
@@ -307,6 +307,13 @@ export function createMemoryLoggerSubagent(
307
307
  const logger = options.logger ?? consoleLogger
308
308
  return {
309
309
  systemPrompt: MEMORY_LOGGER_SYSTEM_PROMPT,
310
+ // Logging is "read transcript past the watermark, decide 0-N fragments,
311
+ // append" — mechanical extraction, no deep reasoning. Without this it fell
312
+ // back to `default`, sharing the slow reasoning model that a concurrent
313
+ // `researcher` pass saturates, which made the 50s spawn timeout fire under
314
+ // load. `fast` matches `memory-retrieval` (same I/O-bound shape) and itself
315
+ // falls back to `default` with a one-time warning when unconfigured.
316
+ profile: 'fast',
310
317
  tools: [readTool],
311
318
  customTools: [findEntryTool, appendTool, advanceWatermarkTool],
312
319
  payloadSchema: memoryLoggerPayloadSchema,