typeclaw 0.34.0 → 0.35.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 (36) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +53 -5
  3. package/src/agent/provider-error.ts +10 -0
  4. package/src/agent/session-origin.ts +26 -0
  5. package/src/agent/tools/channel-disengage.ts +13 -9
  6. package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
  7. package/src/bundled-plugins/github-cli-auth/git-askpass.ts +65 -0
  8. package/src/bundled-plugins/github-cli-auth/git-command.ts +638 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +138 -38
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
  11. package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
  12. package/src/channels/adapters/github/inbound.ts +41 -3
  13. package/src/channels/adapters/slack-bot.ts +17 -9
  14. package/src/channels/continuation-willingness.ts +331 -0
  15. package/src/channels/github-review-claim.ts +105 -0
  16. package/src/channels/github-token-bridge.ts +7 -0
  17. package/src/channels/router.ts +103 -24
  18. package/src/cli/channel.ts +102 -11
  19. package/src/cli/qr.ts +130 -0
  20. package/src/config/config.ts +98 -2
  21. package/src/container/start.ts +12 -0
  22. package/src/init/dockerfile.ts +64 -0
  23. package/src/init/line-auth.ts +8 -3
  24. package/src/plugin/context.ts +5 -1
  25. package/src/plugin/manager.ts +2 -0
  26. package/src/plugin/types.ts +1 -0
  27. package/src/run/index.ts +1 -0
  28. package/src/sandbox/build.ts +27 -0
  29. package/src/sandbox/index.ts +6 -0
  30. package/src/sandbox/package-install.ts +23 -0
  31. package/src/sandbox/policy.ts +31 -0
  32. package/src/sandbox/symlinks.ts +34 -0
  33. package/src/sandbox/writable-zones.ts +164 -4
  34. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  35. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  36. package/typeclaw.schema.json +32 -1
@@ -0,0 +1,638 @@
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
+ // The single permitted `cd <simple-path> && git …` shortcut is rewritten to a
95
+ // bare `git -C …` before any chain parsing, so a chain never has to reason
96
+ // about a `cd` segment changing cwd for the gits that follow it. Multi-git
97
+ // chains must use explicit `git -C` per segment instead.
98
+ const stripped = stripSafeCdPrefix(command)
99
+ if (stripped.unsafe) return { kind: 'pass-through' }
100
+ if (stripped.cdDir !== null) return analyzeSingleCdGit(stripped, options)
101
+
102
+ // Split the command into `&&`-joined git segments. null = not a pure
103
+ // git-only `&&` chain (a non-git segment, a forbidden shell operator, a
104
+ // leading env assignment, or no git at all).
105
+ const chain = extractGitInvocationChain(command)
106
+ if (chain === null) {
107
+ // Distinguish "no git here at all" (pass-through) from "git is present but
108
+ // the composition is unsafe" (block). A bare `ls`/`echo` is not our concern;
109
+ // a `git push … | tee` or `git … && curl evil` must be blocked, never run
110
+ // tokenless (which is what silently passing through used to do — the bug).
111
+ return containsGitInvocation(command) ? { kind: 'block', reason: COMPOSITION_REASON } : { kind: 'pass-through' }
112
+ }
113
+
114
+ const remoteSegments = chain.filter((s) => {
115
+ const sub = findSubcommand(s.args)
116
+ return sub !== undefined && REMOTE_SUBCOMMANDS.has(sub)
117
+ })
118
+ // No segment talks to a remote (e.g. `git status && git log`): nothing to
119
+ // authenticate, so pass through and let git run with no token.
120
+ if (remoteSegments.length === 0) return { kind: 'pass-through' }
121
+
122
+ // Every segment — even non-remote ones like `git status` that ride along in
123
+ // the chain — must pass the redirect screen: a token lives in the shared env
124
+ // the whole chain inherits, so a `-c`/env-assignment on ANY git could steer
125
+ // auth or destination.
126
+ for (const seg of chain) {
127
+ if (seg.hasLeadingEnvAssignment || hasDangerousGitConfig(seg.args)) {
128
+ return { kind: 'block', reason: DANGEROUS_CONFIG_REASON }
129
+ }
130
+ const sub = findSubcommand(seg.args)
131
+ if ((sub === 'fetch' || sub === 'pull') && seg.args.includes('--all')) {
132
+ return { kind: 'block', reason: FETCH_ALL_REASON }
133
+ }
134
+ }
135
+
136
+ const owners = new Set<string>()
137
+ let representativeSlug: string | null = null
138
+ for (const seg of remoteSegments) {
139
+ const sub = findSubcommand(seg.args) as string
140
+ const effectiveCwd = resolveCwd(options.cwd, extractDashCDir(seg.args))
141
+ // A resolver that throws is treated as "couldn't resolve" → pass-through, so
142
+ // a git/subprocess failure never crashes the hook or guesses a repo.
143
+ let slugs: string[]
144
+ try {
145
+ slugs = await resolveRepoSlugs(sub, seg.args, effectiveCwd, options.resolvers)
146
+ } catch {
147
+ return { kind: 'pass-through' }
148
+ }
149
+ for (const slug of slugs) {
150
+ owners.add(slug.split('/')[0] as string)
151
+ representativeSlug ??= slug
152
+ }
153
+ }
154
+
155
+ // A GitHub remote subcommand whose target owner we could not resolve: we must
156
+ // not mint a possibly-wrong-owner token, and silently passing through would
157
+ // reproduce the credential-less failure. Block with an actionable reason.
158
+ if (representativeSlug === null) return { kind: 'pass-through' }
159
+ if (owners.size > 1) return { kind: 'block', reason: MULTI_OWNER_REASON }
160
+
161
+ return { kind: 'inject', repoSlug: representativeSlug }
162
+ }
163
+
164
+ // The single-git `cd <path> && git …` shortcut. Kept separate (and single-git
165
+ // only) so the cwd rewrite stays simple: the token-bearing command becomes one
166
+ // bare `git -C <path> …`, with no sibling inheriting the env. A `cd` in a
167
+ // multi-git chain is rejected (callers use explicit `git -C` per segment).
168
+ async function analyzeSingleCdGit(
169
+ stripped: StrippedCd,
170
+ options: { cwd: string; resolvers: GitResolvers },
171
+ ): Promise<GitCommandDecision> {
172
+ const segment = extractSingleGitInvocation(stripped.rest)
173
+ if (segment === null) return { kind: 'pass-through' }
174
+ const args = segment.args
175
+ const subcommand = findSubcommand(args)
176
+ if (subcommand === undefined || !REMOTE_SUBCOMMANDS.has(subcommand)) return { kind: 'pass-through' }
177
+ if (segment.hasLeadingEnvAssignment || hasDangerousGitConfig(args)) {
178
+ return { kind: 'block', reason: DANGEROUS_CONFIG_REASON }
179
+ }
180
+ if ((subcommand === 'fetch' || subcommand === 'pull') && args.includes('--all')) {
181
+ return { kind: 'block', reason: FETCH_ALL_REASON }
182
+ }
183
+ const dashCDir = extractDashCDir(args)
184
+ const effectiveCwd = resolveCwd(resolveCwd(options.cwd, stripped.cdDir), dashCDir)
185
+ let slugs: string[]
186
+ try {
187
+ slugs = await resolveRepoSlugs(subcommand, args, effectiveCwd, options.resolvers)
188
+ } catch {
189
+ return { kind: 'pass-through' }
190
+ }
191
+ if (slugs.length === 0) return { kind: 'pass-through' }
192
+ const owners = new Set(slugs.map((s) => s.split('/')[0]))
193
+ if (owners.size > 1) return { kind: 'block', reason: MULTI_OWNER_REASON }
194
+ const repoSlug = slugs[0] as string
195
+ // The rewrite cannot stack two `-C` (the git part must not already carry one)
196
+ // and the rest must be a single bare git (no further composition).
197
+ if (containsShellActiveMetachar(stripped.rest) || dashCDir !== null) {
198
+ return { kind: 'block', reason: COMPOSITION_REASON }
199
+ }
200
+ return { kind: 'inject', repoSlug, rewrittenCommand: rewriteCdToDashC(effectiveCwd, stripped.rest) }
201
+ }
202
+
203
+ // Global flags that can redirect git's auth or destination. `-c`/`--config-env`
204
+ // inject arbitrary config (url.insteadOf, core.askPass, credential.*, http.*);
205
+ // `--git-dir`/`--work-tree`/`--namespace`/`--exec-path` point git at a different
206
+ // repo or helper than the one we resolved. Any of these on a token-bearing
207
+ // command means we cannot vouch for where the token goes — block.
208
+ function hasDangerousGitConfig(args: readonly string[]): boolean {
209
+ for (const arg of args) {
210
+ // git accepts both `--config-env <name>=<envvar>` and the inline
211
+ // `--config-env=<name>=<envvar>`; both must be caught. (`-c` has no inline
212
+ // `-c=…` form — git always takes the next token.)
213
+ if (arg === '-c' || arg === '--config-env' || arg.startsWith('--config-env=')) return true
214
+ if (arg === '--git-dir' || arg.startsWith('--git-dir=')) return true
215
+ if (arg === '--work-tree' || arg.startsWith('--work-tree=')) return true
216
+ if (arg === '--namespace' || arg.startsWith('--namespace=')) return true
217
+ if (arg === '--exec-path' || arg.startsWith('--exec-path=')) return true
218
+ }
219
+ return false
220
+ }
221
+
222
+ async function resolveRepoSlugs(
223
+ subcommand: string,
224
+ args: readonly string[],
225
+ cwd: string,
226
+ resolvers: GitResolvers,
227
+ ): Promise<string[]> {
228
+ const explicitUrl = extractExplicitUrl(subcommand, args)
229
+ if (explicitUrl !== null) {
230
+ const slug = parseGithubRepoFromGitUrl(explicitUrl)
231
+ return slug === null ? [] : [slug]
232
+ }
233
+
234
+ // clone needs an explicit URL; it has no configured-remote fallback.
235
+ if (subcommand === 'clone') return []
236
+
237
+ // Only `push` consults the push url; fetch/pull/ls-remote use the fetch url.
238
+ const forPush = subcommand === 'push'
239
+
240
+ // EVERY named remote must be resolved, not just the first: `git fetch
241
+ // --multiple origin upstream` touches both, and feeding only one to the
242
+ // multi-owner guard would let a second-owner remote slip past and still mint.
243
+ const remoteNames = extractRemoteNames(args)
244
+ // The branch.pushRemote/pushDefault/origin chain is a PUSH default; applying it
245
+ // to a bare `fetch`/`pull`/`ls-remote` could mint for the wrong (push) remote,
246
+ // so only push falls back. Other subcommands with no explicit remote pass through.
247
+ const remotes =
248
+ remoteNames.length > 0 ? remoteNames : subcommand === 'push' ? [await resolveDefaultPushRemote(cwd, resolvers)] : []
249
+ if (remotes.length === 0) return []
250
+
251
+ const slugs: string[] = []
252
+ for (const remote of remotes) {
253
+ if (remote === null) continue
254
+ const url = looksLikeUrl(remote) ? remote : await resolvers.resolveRemoteUrl(cwd, remote, forPush)
255
+ if (url === null) continue
256
+ const slug = parseGithubRepoFromGitUrl(url)
257
+ if (slug !== null) slugs.push(slug)
258
+ }
259
+ return slugs
260
+ }
261
+
262
+ // Mirrors git's own push-remote resolution order for a bare `git push`.
263
+ async function resolveDefaultPushRemote(cwd: string, resolvers: GitResolvers): Promise<string | null> {
264
+ const branch = await resolvers.resolveCurrentBranch(cwd)
265
+ if (branch !== null && branch !== '') {
266
+ const perBranch = await resolvers.resolveConfig(cwd, `branch.${branch}.pushRemote`)
267
+ if (perBranch !== null && perBranch !== '') return perBranch
268
+ }
269
+ const pushDefault = await resolvers.resolveConfig(cwd, 'remote.pushDefault')
270
+ if (pushDefault !== null && pushDefault !== '') return pushDefault
271
+ return 'origin'
272
+ }
273
+
274
+ const HTTPS_GITHUB_RE = /^https:\/\/github\.com\/([^/\s:@]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
275
+ const SCP_GITHUB_RE = /^git@github\.com:([^/\s:?#]+)\/([^/\s?#]+?)(?:\.git)?$/i
276
+ const SSH_GITHUB_RE = /^ssh:\/\/git@github\.com(?::\d+)?\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
277
+
278
+ // Parses a github.com remote URL into an `owner/name` slug. Returns null for
279
+ // non-github.com hosts, credential-bearing https URLs (https://tok@github.com/…
280
+ // — we never reuse an embedded credential), local paths, or malformed input.
281
+ export function parseGithubRepoFromGitUrl(raw: string): string | null {
282
+ const url = raw.trim()
283
+ for (const re of [HTTPS_GITHUB_RE, SCP_GITHUB_RE, SSH_GITHUB_RE]) {
284
+ const m = url.match(re)
285
+ if (m === null) continue
286
+ const owner = m[1]
287
+ const name = m[2]
288
+ if (owner === undefined || name === undefined || owner === '' || name === '') return null
289
+ return `${owner}/${name}`
290
+ }
291
+ return null
292
+ }
293
+
294
+ function looksLikeUrl(token: string): boolean {
295
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token)) return true
296
+ if (/^[^@\s]+@[^:\s]+:/.test(token)) return true
297
+ return false
298
+ }
299
+
300
+ // Flags that consume the FOLLOWING token, so a positional after them (the
301
+ // subcommand or a remote/URL) is not mistaken for the flag's value. Includes the
302
+ // dangerous-config flags in their separate-arg form (`--config-env <v>`) so the
303
+ // real subcommand is still located and hasDangerousGitConfig can fire.
304
+ const GIT_VALUE_FLAGS = new Set([
305
+ '-C',
306
+ '-c',
307
+ '--config-env',
308
+ '--git-dir',
309
+ '--work-tree',
310
+ '--namespace',
311
+ '--exec-path',
312
+ '-o',
313
+ '--origin',
314
+ '-b',
315
+ '--branch',
316
+ '-u',
317
+ ])
318
+
319
+ function extractExplicitUrl(subcommand: string, args: readonly string[]): string | null {
320
+ // `git push --repo <url>` / `--repo=<url>`
321
+ for (let i = 0; i < args.length; i++) {
322
+ const arg = args[i] as string
323
+ if (arg === '--repo' || arg === '--repository') {
324
+ const v = args[i + 1]
325
+ if (v !== undefined && looksLikeUrl(v)) return v
326
+ }
327
+ if (arg.startsWith('--repo=')) return arg.slice('--repo='.length)
328
+ if (arg.startsWith('--repository=')) return arg.slice('--repository='.length)
329
+ }
330
+ // First positional after the subcommand that looks like a URL.
331
+ for (const pos of positionalsAfterSubcommand(subcommand, args)) {
332
+ if (looksLikeUrl(pos)) return pos
333
+ }
334
+ return null
335
+ }
336
+
337
+ // The git subcommand is the first positional that is NOT consumed by a global
338
+ // value-flag (`git -C <dir> push`, `git -c k=v push`). A naive first-non-flag
339
+ // scan would pick the flag's value (e.g. `<dir>`) as the subcommand.
340
+ function findSubcommand(args: readonly string[]): string | undefined {
341
+ for (let i = 0; i < args.length; i++) {
342
+ const arg = args[i] as string
343
+ if (arg.startsWith('-')) {
344
+ if (!arg.includes('=') && GIT_VALUE_FLAGS.has(arg)) i += 1
345
+ continue
346
+ }
347
+ return arg
348
+ }
349
+ return undefined
350
+ }
351
+
352
+ // The remote name(s) a command targets. Normally just the first positional —
353
+ // later positionals are refspecs (`git push origin main` -> remote `origin`,
354
+ // refspec `main`). With `--multiple` (fetch), EVERY positional is a remote, so
355
+ // all are returned to feed the multi-owner guard. URL positionals are excluded
356
+ // here; resolveRepoSlugs handles a bare-URL target directly.
357
+ function extractRemoteNames(args: readonly string[]): string[] {
358
+ const sub = findSubcommand(args)
359
+ if (sub === undefined) return []
360
+ const positionals = positionalsAfterSubcommand(sub, args).filter((p) => !looksLikeUrl(p))
361
+ if (positionals.length === 0) return []
362
+ if (args.includes('--multiple')) return positionals
363
+ return [positionals[0] as string]
364
+ }
365
+
366
+ function positionalsAfterSubcommand(subcommand: string, args: readonly string[]): string[] {
367
+ const out: string[] = []
368
+ let seenSub = false
369
+ for (let i = 0; i < args.length; i++) {
370
+ const arg = args[i] as string
371
+ if (arg.startsWith('-')) {
372
+ if (!arg.includes('=') && GIT_VALUE_FLAGS.has(arg)) i += 1
373
+ continue
374
+ }
375
+ if (!seenSub) {
376
+ if (arg === subcommand) seenSub = true
377
+ continue
378
+ }
379
+ out.push(arg)
380
+ }
381
+ return out
382
+ }
383
+
384
+ function extractDashCDir(args: readonly string[]): string | null {
385
+ for (let i = 0; i < args.length; i++) {
386
+ const arg = args[i]
387
+ if (arg === '-C') {
388
+ const v = args[i + 1]
389
+ if (v !== undefined) return stripQuotes(v)
390
+ }
391
+ }
392
+ return null
393
+ }
394
+
395
+ type GitInvocation = { args: string[]; hasLeadingEnvAssignment: boolean }
396
+
397
+ // Null unless there is exactly one `git` at command position. Composition is NOT
398
+ // rejected here — it is screened later against the original command so the block
399
+ // reason stays accurate. hasLeadingEnvAssignment flags `FOO=bar git …`: such an
400
+ // assignment lands in git's env (where a `GIT_ASKPASS=…` override would receive
401
+ // the token), so the caller blocks it for token-bearing commands.
402
+ function extractSingleGitInvocation(command: string): GitInvocation | null {
403
+ const tokens = tokenize(command)
404
+ const gitStarts: number[] = []
405
+ for (let i = 0; i < tokens.length; i++) {
406
+ if (tokens[i] !== 'git') continue
407
+ if (i === 0 || isCommandBoundaryBefore(tokens, i)) gitStarts.push(i)
408
+ }
409
+ if (gitStarts.length !== 1) return null
410
+ const start = gitStarts[0] as number
411
+ const hasLeadingEnvAssignment = start > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start - 1] ?? '')
412
+ const args = tokens.slice(start + 1).filter((t) => t !== '\n' && t !== ';' && t !== '|' && t !== '&')
413
+ return { args, hasLeadingEnvAssignment }
414
+ }
415
+
416
+ // True if `git` appears at any command boundary. Used to decide block-vs-pass:
417
+ // a command with no git at all is none of our business (pass-through), but one
418
+ // that DOES contain git yet is not a clean git-only `&&` chain must be blocked,
419
+ // not run tokenless.
420
+ function containsGitInvocation(command: string): boolean {
421
+ const tokens = tokenize(command)
422
+ for (let i = 0; i < tokens.length; i++) {
423
+ if (tokens[i] === 'git' && (i === 0 || isCommandBoundaryBefore(tokens, i))) return true
424
+ }
425
+ return false
426
+ }
427
+
428
+ // Splits a command into `&&`-joined segments and accepts it ONLY when EVERY
429
+ // segment is a single bare `git` invocation. Returns null (caller blocks or
430
+ // passes through) when any segment is non-git, the chain uses any operator other
431
+ // than `&&` (`;`, `|`, `||`, `&`, newline) at top level, or a segment carries a
432
+ // shell-active metachar (`$`, backtick, redirection, subshell) that could spawn
433
+ // or feed a NON-git process — which would inherit the shared token env. The
434
+ // all-git invariant is load-bearing: the token rides in a plain inherited env
435
+ // var, so a single non-git sibling could read it directly.
436
+ function extractGitInvocationChain(command: string): GitInvocation[] | null {
437
+ // Quote-aware screen on the ORIGINAL command: every shell-active metachar
438
+ // except the `&&` chain operator must be absent. This rejects `;`, `|`, `||`,
439
+ // single `&`, redirection, subshells, and `$`/backtick (active outside single
440
+ // quotes) — anything that could spawn or feed a non-git process that would
441
+ // inherit the shared token env. Screening the original (not dequoted tokens)
442
+ // keeps a safely single-quoted `$` from being a false positive.
443
+ if (containsUnsafeMetacharOutsideAndAnd(command)) return null
444
+
445
+ const tokens = tokenize(command)
446
+ if (tokens.length === 0) return null
447
+
448
+ const segments: string[][] = [[]]
449
+ for (const token of tokens) {
450
+ if (token === '&&') {
451
+ segments.push([])
452
+ continue
453
+ }
454
+ // The metachar screen above already rejected every other operator; this is
455
+ // belt-and-suspenders in case the tokenizer ever emits one we missed.
456
+ if (token === ';' || token === '|' || token === '||' || token === '&' || token === '\n') return null
457
+ ;(segments[segments.length - 1] as string[]).push(token)
458
+ }
459
+
460
+ const invocations: GitInvocation[] = []
461
+ for (const seg of segments) {
462
+ if (seg.length === 0) return null
463
+ let start = 0
464
+ while (start < seg.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[start] ?? '')) start += 1
465
+ const hasLeadingEnvAssignment = start > 0
466
+ if (seg[start] !== 'git') return null
467
+ invocations.push({ args: seg.slice(start + 1), hasLeadingEnvAssignment })
468
+ }
469
+ return invocations
470
+ }
471
+
472
+ // Quote-aware: true if any shell-active metachar appears outside quotes EXCEPT
473
+ // the `&&` operator (a lone `&` IS unsafe — backgrounding). `$`/backtick are
474
+ // active inside double quotes too, so they trip even there; single-quoted
475
+ // content is inert. Companion to containsShellActiveMetachar, which forbids ALL
476
+ // of them (no `&&` exception) for the single-git path.
477
+ function containsUnsafeMetacharOutsideAndAnd(command: string): boolean {
478
+ let quote: '"' | "'" | null = null
479
+ for (let i = 0; i < command.length; i++) {
480
+ const ch = command[i] as string
481
+ if (quote === "'") {
482
+ if (ch === "'") quote = null
483
+ continue
484
+ }
485
+ if (quote === '"') {
486
+ if (ch === '$' || ch === '`') return true
487
+ if (ch === '"') quote = null
488
+ continue
489
+ }
490
+ if (ch === "'" || ch === '"') {
491
+ quote = ch
492
+ continue
493
+ }
494
+ if (ch === '&') {
495
+ // `&&` is the one allowed composer; a single `&` is backgrounding.
496
+ if (command[i + 1] === '&') {
497
+ i += 1
498
+ continue
499
+ }
500
+ return true
501
+ }
502
+ if (SHELL_ACTIVE_METACHARS.has(ch)) return true
503
+ }
504
+ return false
505
+ }
506
+
507
+ function isCommandBoundaryBefore(tokens: readonly string[], index: number): boolean {
508
+ let cursor = index - 1
509
+ while (cursor >= 0) {
510
+ const prev = tokens[cursor]
511
+ if (prev === undefined) return false
512
+ if (prev === '&&' || prev === '||' || prev === '|' || prev === ';' || prev === '\n') return true
513
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(prev)) {
514
+ cursor -= 1
515
+ continue
516
+ }
517
+ return false
518
+ }
519
+ return true
520
+ }
521
+
522
+ function containsShellActiveMetachar(command: string): boolean {
523
+ let quote: '"' | "'" | null = null
524
+ for (let i = 0; i < command.length; i++) {
525
+ const ch = command[i] as string
526
+ if (quote === "'") {
527
+ if (ch === "'") quote = null
528
+ continue
529
+ }
530
+ if (quote === '"') {
531
+ if (ch === '$' || ch === '`') return true
532
+ if (ch === '"') quote = null
533
+ continue
534
+ }
535
+ if (ch === "'" || ch === '"') {
536
+ quote = ch
537
+ continue
538
+ }
539
+ if (SHELL_ACTIVE_METACHARS.has(ch)) return true
540
+ }
541
+ return false
542
+ }
543
+
544
+ // unsafe=true when the command LOOKS like a `cd … && git …` but the cd target is
545
+ // not a plain path we can faithfully turn into `git -C` (shell `~`/`-` expansion,
546
+ // metachars). The caller then passes through rather than rewrite wrong cwd.
547
+ type StrippedCd = { cdDir: string | null; rest: string; unsafe: boolean }
548
+
549
+ // Accepts ONLY `cd <simple-path> && git …`. <simple-path> must be a single
550
+ // token (optionally quoted) free of metachars and of a literal single quote
551
+ // (rewriteCdToDashC single-quotes it), and not a shell-expanded `~`/`-`. A plain
552
+ // command (no `cd` prefix) returns cdDir=null, unsafe=false.
553
+ function stripSafeCdPrefix(command: string): StrippedCd {
554
+ const m = command.match(/^\s*cd\s+("[^"]*"|'[^']*'|[^\s'"]+)\s+&&\s+(git\b[\s\S]*)$/)
555
+ if (m === null) return { cdDir: null, rest: command, unsafe: false }
556
+ const rawDir = m[1] as string
557
+ const rest = m[2] as string
558
+ const dir = stripQuotes(rawDir)
559
+ const shellExpands = dir === '-' || dir === '~' || dir.startsWith('~/')
560
+ if (dir.includes('$') || dir.includes('`') || dir.includes("'") || /[;|&<>()]/.test(dir) || shellExpands) {
561
+ return { cdDir: null, rest: command, unsafe: true }
562
+ }
563
+ return { cdDir: dir, rest, unsafe: false }
564
+ }
565
+
566
+ function rewriteCdToDashC(dir: string, gitCommand: string): string {
567
+ return gitCommand.replace(/^git\b/, `git -C '${dir}'`)
568
+ }
569
+
570
+ function resolveCwd(base: string, dir: string | null): string {
571
+ if (dir === null || dir === '') return base
572
+ if (dir.startsWith('/')) return dir
573
+ return `${base.replace(/\/$/, '')}/${dir}`
574
+ }
575
+
576
+ function stripQuotes(token: string): string {
577
+ if (token.length < 2) return token
578
+ const first = token[0]
579
+ const last = token[token.length - 1]
580
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return token.slice(1, -1)
581
+ return token
582
+ }
583
+
584
+ // Quote-aware; emits shell control operators as standalone tokens so
585
+ // command-boundary detection works. Mirrors gh-command.ts's tokenize.
586
+ function tokenize(command: string): string[] {
587
+ const tokens: string[] = []
588
+ let current = ''
589
+ let quote: '"' | "'" | null = null
590
+ let hasContent = false
591
+
592
+ const flush = (): void => {
593
+ if (hasContent) {
594
+ tokens.push(current)
595
+ current = ''
596
+ hasContent = false
597
+ }
598
+ }
599
+
600
+ for (let i = 0; i < command.length; i++) {
601
+ const ch = command[i]
602
+ if (ch === undefined) continue
603
+ if (quote !== null) {
604
+ if (ch === quote) quote = null
605
+ else current += ch
606
+ continue
607
+ }
608
+ if (ch === '"' || ch === "'") {
609
+ quote = ch
610
+ hasContent = true
611
+ continue
612
+ }
613
+ if (ch === ' ' || ch === '\t') {
614
+ flush()
615
+ continue
616
+ }
617
+ if (ch === '\n') {
618
+ flush()
619
+ tokens.push('\n')
620
+ continue
621
+ }
622
+ if (ch === ';' || ch === '|' || ch === '&') {
623
+ flush()
624
+ const next = command[i + 1]
625
+ if ((ch === '|' && next === '|') || (ch === '&' && next === '&')) {
626
+ tokens.push(ch + ch)
627
+ i += 1
628
+ } else {
629
+ tokens.push(ch)
630
+ }
631
+ continue
632
+ }
633
+ current += ch
634
+ hasContent = true
635
+ }
636
+ flush()
637
+ return tokens
638
+ }