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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -53,6 +53,7 @@
53
53
  "cron-parser": "^5.5.0",
54
54
  "jq-wasm": "^1.1.0-jq-1.8.1",
55
55
  "jsdom": "^29.0.2",
56
+ "qrcode": "^1.5.4",
56
57
  "turndown": "^7.2.4",
57
58
  "zod": "^4.3.6"
58
59
  },
@@ -61,6 +62,7 @@
61
62
  "@types/bun": "latest",
62
63
  "@types/jsdom": "^28.0.1",
63
64
  "@types/proper-lockfile": "^4.1.4",
65
+ "@types/qrcode": "^1.5.6",
64
66
  "@types/sinonjs__fake-timers": "^15.0.1",
65
67
  "@types/turndown": "^5.0.6",
66
68
  "@types/ws": "^8.18.1",
@@ -23,7 +23,7 @@ import {
23
23
  checkNonWorkspaceWriteGuard,
24
24
  checkSkillAuthoringGuard,
25
25
  } from '@/bundled-plugins/guard/policy'
26
- import { config } from '@/config/config'
26
+ import { config, getSandboxWritablePathSpecs } from '@/config/config'
27
27
  import type { PermissionService } from '@/permissions/permissions'
28
28
  import type {
29
29
  BuiltinToolRef,
@@ -39,12 +39,16 @@ import {
39
39
  buildSandboxedCommand,
40
40
  canBindProcSafely,
41
41
  canMountRealProc,
42
+ DEFAULT_SANDBOX_ENV,
42
43
  ensureBwrapAvailable,
43
44
  ensureSessionTmpDir,
45
+ isPackageInstallCommand,
44
46
  mapVirtualTmpPath,
45
47
  resolveHiddenPaths,
48
+ resolvePackageInstallZones,
46
49
  resolveProcSelfExe,
47
50
  resolveProtectedZones,
51
+ resolveSandboxSymlinks,
48
52
  resolveWritableZones,
49
53
  type SandboxProcStrategy,
50
54
  subtractMasked,
@@ -590,7 +594,17 @@ async function applyBashSandbox(
590
594
  // (their masks are empty) and keep full unsandboxed access. subtractMasked
591
595
  // drops any writable zone masked for this role so an RW bind never re-exposes a
592
596
  // hidden path (e.g. a guest's masked workspace/).
593
- const writable = subtractMasked(await resolveWritableZones(agentDir), { dirs, files })
597
+ // config.sandbox.* is read from the BOOT-TIME snapshot, not getConfig():
598
+ // sandbox is restart-required, so the writable surface AND the in-jail symlinks
599
+ // must stay coherent with the boot-time bwrap/capability decisions and with the
600
+ // entrypoint shim's symlink creation (same contract as resolveProcStrategy's
601
+ // read of config.sandbox.realProc below). getSandboxWritablePathSpecs folds
602
+ // every sandbox.symlinks[].to into the writable set so the symlink target is
603
+ // writable without the operator also listing it under writablePaths.
604
+ const writable = subtractMasked(await resolveWritableZones(agentDir, getSandboxWritablePathSpecs(config)), {
605
+ dirs,
606
+ files,
607
+ })
594
608
  // subtractMasked again on the protected set: a protected RO bind renders after
595
609
  // the masks (last-op-wins), so an unfiltered protected path nested under a
596
610
  // masked dir (e.g. a guest's workspace/ when core.hooksPath=workspace/hooks)
@@ -599,6 +613,31 @@ async function applyBashSandbox(
599
613
  const protectedZones = writable.dirs.includes(join(agentDir, '.git'))
600
614
  ? subtractMasked(await resolveProtectedZones(agentDir), { dirs, files })
601
615
  : { dirs: [], files: [] }
616
+ // A recognized standalone `bun add`/`bun install` needs DIRECTORY write at the
617
+ // root (node_modules/ + temp lockfile), which the narrow carve-out model can't
618
+ // grant. Widen to an RW root for that command class only, then re-hide secrets
619
+ // (masks) and re-RO the executable surfaces (resolvePackageInstallZones) on top
620
+ // — subtractMasked drops any protected path a mask already hides so the RW root
621
+ // never re-exposes it. The default jail's `writable`/`protected` are skipped
622
+ // here: writableRoot supersedes them for this command, and the same masks +
623
+ // package-install protected set cover the confidentiality and escalation
624
+ // boundaries.
625
+ const packageInstall = isPackageInstallCommand(command)
626
+ ? subtractMaskedProtected(await resolvePackageInstallZones(agentDir), { dirs, files })
627
+ : undefined
628
+
629
+ // Only emit an in-jail symlink for a target that actually survived as a
630
+ // writable dir: a symlink to a target that was dropped (missing, masked for
631
+ // this role, or filtered by the writable-zone guardrails) would dangle onto an
632
+ // EROFS/hidden path. Resolve `from` against the SANDBOX HOME (/tmp), where the
633
+ // per-session tmp dir is bound — NOT the container's real /root, which the jail
634
+ // never sees. The entrypoint shim handles the real-/root symlink for the
635
+ // unsandboxed (trusted/owner) path.
636
+ const writableDirSet = new Set(writable.dirs)
637
+ const sandboxHome = DEFAULT_SANDBOX_ENV.HOME ?? '/tmp'
638
+ const symlinks = resolveSandboxSymlinks(agentDir, config.sandbox.symlinks, sandboxHome).filter((op) =>
639
+ writableDirSet.has(op.target),
640
+ )
602
641
  // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
603
642
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
604
643
  // path does not run when the command is rewritten to a bwrap invocation).
@@ -608,9 +647,10 @@ async function applyBashSandbox(
608
647
  { type: 'ro-bind', source: agentDir, dest: agentDir },
609
648
  { type: 'bind', source: sessionTmp, dest: '/tmp' },
610
649
  ],
611
- masks: { dirs, files },
612
- writable,
613
- protected: protectedZones,
650
+ ...(packageInstall !== undefined
651
+ ? { writableRoot: { dir: packageInstall.root }, masks: { dirs, files }, protected: packageInstall.protected }
652
+ : { masks: { dirs, files }, writable, protected: protectedZones }),
653
+ symlinks,
614
654
  network: 'inherit',
615
655
  cwd: agentDir,
616
656
  proc,
@@ -620,6 +660,14 @@ async function applyBashSandbox(
620
660
  mutableArgs.command = commandString
621
661
  }
622
662
 
663
+ function subtractMaskedProtected(
664
+ zones: { root: string; protected: { dirs: string[]; files: string[] } },
665
+ masked: { dirs: string[]; files: string[] },
666
+ ): { root: string; protected: { dirs: string[]; files: string[] } } {
667
+ const filtered = subtractMasked(zones.protected, masked)
668
+ return { root: zones.root, protected: filtered }
669
+ }
670
+
623
671
  // Picks the /proc strategy for a sandboxed bash call. The branch order is:
624
672
  // 'real-proc' ONLY when the operator explicitly opted in (sandbox.realProc) AND
625
673
  // the kernel permits the mount (canMountRealProc) — it adds PID isolation but
@@ -31,6 +31,16 @@ const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can che
31
31
  // specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
32
32
  // text, so adding a new class is opt-in and never widens what we expose.
33
33
  const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
34
+ {
35
+ // Auth failure: the provider rejected our credentials (bad/expired/missing
36
+ // API key). Matched first because a 401 body can also mention "account",
37
+ // which would otherwise fall into the billing class below. The safe text
38
+ // names the operator action (check the API key) without echoing the raw
39
+ // error, whose body can carry a Bearer token.
40
+ match:
41
+ /\b(401|unauthori[sz]ed|invalid[_ -]?api[_ -]?key|api key.*(?:invalid|expired|missing)|authentication failed|invalid bearer)\b/i,
42
+ safe: 'The upstream LLM provider rejected the request as unauthorized. Operators should check the provider API key configuration and `typeclaw logs`.',
43
+ },
34
44
  {
35
45
  match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
36
46
  safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
@@ -370,6 +370,17 @@ function renderChannelOrigin(
370
370
  'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
371
371
  '(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
372
372
  'for the actual substantive answer.',
373
+ '',
374
+ '**A formal review verdict already IS the comment — never post it twice.**',
375
+ 'When you submit a PR review (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`),',
376
+ 'the review body renders on the PR as a comment, visually identical to a',
377
+ 'plain comment. So put your entire verdict — the "approved", the praise,',
378
+ 'the findings — in that review body. Do NOT then post the same (or',
379
+ 'paraphrased) text again as a separate `channel_reply` / `gh pr comment`:',
380
+ 'that is a visible duplicate, the exact same words landing twice seconds',
381
+ 'apart. One verdict, one surface. If you have already submitted the review,',
382
+ 'the PR has heard you — `skip_response({ reason: "verdict posted as review" })`',
383
+ 'instead of echoing it as a comment.',
373
384
  )
374
385
  }
375
386
 
@@ -442,6 +453,21 @@ function renderChannelOrigin(
442
453
  ' natural terminal action for the turn. Pair it with `skip_response` when',
443
454
  ' you also want to stay silent this turn.',
444
455
  '',
456
+ ' **An explicit quiet command is a direct order to call this tool.** When',
457
+ ' someone tells you to stop — e.g. "disengage", "be quiet", "stop replying",',
458
+ ' "stop", "back off", "stay out of this", "shush", or "조용" / "조용히 해" /',
459
+ ' "그만" / "빠져" / "тихо" / "tais-toi" / "cállate" / "ruhig" / "黙って" /',
460
+ ' "安静" in any language — you MUST call `channel_disengage` that same turn.',
461
+ ' Posting a `channel_reply` like "ok, I\'ll be quiet" is NOT enough on its',
462
+ ' own: a reply alone re-grants the very stickiness they asked you to drop,',
463
+ ' so without the `channel_disengage` call you stay engaged and keep getting',
464
+ ' pulled back in — exactly what they told you to stop. The acknowledgement',
465
+ ' does not disengage you; the tool call does. If you ack, ack FIRST with',
466
+ ' `channel_reply`, THEN call `channel_disengage`; if you would rather go',
467
+ ' quiet without a word, call `channel_disengage` alone (optionally with',
468
+ ' `skip_response`). Match intent, not exact words: any clear request to',
469
+ ' stop participating counts, whatever the phrasing or language.',
470
+ '',
445
471
  '**Every user-facing sentence goes through `channel_reply`.** Narrating in',
446
472
  'plain text — "bumping to 16x now", "let me check that" — does NOT reach the',
447
473
  'user; it is invisible. If you want the user to see it, it is a',
@@ -34,15 +34,19 @@ export function createChannelDisengageTool({ router, origin }: CreateChannelDise
34
34
  name: 'channel_disengage',
35
35
  label: 'Channel Disengage',
36
36
  description:
37
- 'Stop auto-engaging on follow-up messages in THIS channel/thread. While engaged you ' +
38
- "keep replying to a participant's next message without an @mention, and that " +
39
- 'engagement is renewed every time you reply — so in a group you can get stuck ' +
40
- 'answering turn after turn even after someone tells you to stop. Call this when a ' +
41
- 'human or peer bot asks you to be quiet / stop replying, or when you realize you are ' +
42
- 'in a redundant loop. After disengaging, you only re-engage in this conversation when ' +
43
- 'explicitly addressed again (mention, reply, or DM). This does not send any message ' +
44
- 'and does not affect other channels. Pair it with skip_response when you also want to ' +
45
- 'stay silent on the current turn.',
37
+ 'Stop auto-engaging on follow-up messages in THIS channel/thread. Call this the moment ' +
38
+ 'a human or peer bot tells you to stop — "disengage", "be quiet", "stop replying", ' +
39
+ '"stop", "back off", or the same in any language (e.g. "조용", "그만", "黙って", ' +
40
+ '"tais-toi", "cállate"). While engaged you keep replying to a participant\'s next ' +
41
+ 'message without an @mention, and that engagement is renewed every time you reply so ' +
42
+ 'in a group you can get stuck answering turn after turn even after someone tells you to ' +
43
+ 'stop. A reply like "ok, I will be quiet" does NOT disengage you; it re-grants the ' +
44
+ 'stickiness they asked you to drop. Only THIS tool drops it. Also call it when you ' +
45
+ 'notice you are in a redundant loop. After disengaging, you only re-engage in this ' +
46
+ 'conversation when explicitly addressed again (mention, reply, or DM). This does not ' +
47
+ 'send any message and does not affect other channels. If you want to acknowledge first, ' +
48
+ 'send the channel_reply BEFORE this call. Pair it with skip_response when you also want ' +
49
+ 'to stay silent on the current turn.',
46
50
  parameters: Type.Object({}),
47
51
 
48
52
  async execute() {
@@ -416,8 +416,7 @@ function stripRepoFlagFromCommand(command: string): string {
416
416
  while (i < command.length) {
417
417
  const ch = command[i] as string
418
418
  if (ch === '"' || ch === "'") {
419
- const close = command.indexOf(ch, i + 1)
420
- const endQuote = close === -1 ? command.length : close
419
+ const endQuote = findClosingQuote(command, i)
421
420
  out += command.slice(i, endQuote + 1)
422
421
  i = endQuote + 1
423
422
  continue
@@ -436,6 +435,24 @@ function stripRepoFlagFromCommand(command: string): string {
436
435
  return out
437
436
  }
438
437
 
438
+ // Index of the quote that closes the one at `open`. Double quotes honor `\"` as
439
+ // a literal (bash processes backslash escapes inside "..."), so a `-R o/r` buried
440
+ // in a `-f body="{\"x\":\"-R o/r\"}"` value is not mistaken for an unquoted flag.
441
+ // Single quotes take everything literally — no escapes — so the next `'` closes.
442
+ // Unterminated quote returns the last index (strip nothing past it).
443
+ function findClosingQuote(command: string, open: number): number {
444
+ const quote = command[open]
445
+ for (let i = open + 1; i < command.length; i++) {
446
+ const ch = command[i]
447
+ if (quote === '"' && ch === '\\') {
448
+ i += 1
449
+ continue
450
+ }
451
+ if (ch === quote) return i
452
+ }
453
+ return command.length - 1
454
+ }
455
+
439
456
  // If `command` has an unquoted `-R`/`--repo` repo-flag token starting at `start`
440
457
  // (at a word boundary), returns the index just past the flag and its value;
441
458
  // otherwise null. Handles `-R o/r`, `--repo o/r`, `-R=o/r`, `--repo=o/r`.
@@ -447,10 +464,16 @@ function matchRepoFlagAt(command: string, start: number): number | null {
447
464
  if (!command.startsWith(flag, start)) continue
448
465
  let i = start + flag.length
449
466
  const sep = command[i]
467
+ // Both value forms validate the slug before stripping: the flag is only a
468
+ // repo flag if its value parses as owner/repo. Without this, the `=` form
469
+ // could strip a non-slug value the detection path would have rejected,
470
+ // diverging detection from rewrite.
450
471
  if (sep === '=') {
451
- i += 1
452
- while (i < command.length && command[i] !== ' ' && command[i] !== '\t') i += 1
453
- return i
472
+ const valueStart = i + 1
473
+ let j = valueStart
474
+ while (j < command.length && command[j] !== ' ' && command[j] !== '\t') j += 1
475
+ if (!isRepoSlug(command.slice(valueStart, j))) return null
476
+ return j
454
477
  }
455
478
  if (sep === ' ' || sep === '\t') {
456
479
  let j = i
@@ -493,9 +516,20 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
493
516
  const flagRepo = extractRepoFlag(args)
494
517
 
495
518
  if (pathRepos.length > 0) {
496
- if (flagRepo !== null && !pathRepos.includes(flagRepo)) {
519
+ // Check EVERY repo flag, not just the first: the strip removes all of them,
520
+ // so a single non-redundant flag anywhere is a mint-for-X-hit-Y attempt and
521
+ // must block even when an earlier flag matches the path and would otherwise
522
+ // mask it.
523
+ const flagRepos = extractAllRepoFlags(args)
524
+ if (flagRepos.some((slug) => !pathRepos.includes(slug))) {
497
525
  return { kind: 'block', reason: API_REPO_CONFLICT_REASON }
498
526
  }
527
+ // Every `-R` here is redundant: it matches the repo already named in the
528
+ // literal path, which is authoritative. `gh api` rejects `-R` outright, so
529
+ // strip the flag rather than let `gh` fail with "unknown shorthand flag".
530
+ // Distinct from graphql (no path, -R IS the hint) — here the path mints the
531
+ // token and the flag is pure noise we remove for syntax.
532
+ if (flagRepos.length > 0) return { kind: 'inject', repoSlugs: pathRepos, stripRepoFlag: true }
499
533
  return { kind: 'inject', repoSlugs: pathRepos }
500
534
  }
501
535
 
@@ -519,6 +553,67 @@ function isGraphqlEndpoint(args: readonly string[]): boolean {
519
553
  return findApiEndpoint(args) === 'graphql'
520
554
  }
521
555
 
556
+ export type GhAuthEnv = {
557
+ GH_TOKEN?: string | undefined
558
+ GITHUB_TOKEN?: string | undefined
559
+ }
560
+
561
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
562
+
563
+ // The effective token `gh` would use for EACH `gh api` invocation that targets the
564
+ // authenticated-user endpoint (`/user`, `user`, or a `/user/...` descendant). The
565
+ // caller classifies each: an App installation token is not a user identity, so
566
+ // GitHub rejects `/user` for it (token-CLASS mismatch, not an auth failure) — but a
567
+ // PAT IS a user identity and works, so the guard must respect a command-local
568
+ // `GH_TOKEN=…`/`GITHUB_TOKEN=…` override on that invocation, not just process env.
569
+ // Precedence mirrors gh: local GH_TOKEN > process GH_TOKEN > local GITHUB_TOKEN >
570
+ // process GITHUB_TOKEN. Narrow endpoint scope: `/users/{username}`, `/meta`,
571
+ // `/rate_limit` are not user-identity endpoints and never match.
572
+ export function effectiveGhTokensForAuthenticatedUserEndpoint(
573
+ command: string,
574
+ env: GhAuthEnv,
575
+ ): Array<string | undefined> {
576
+ const tokens = tokenize(command)
577
+ const ghStarts = findGhInvocations(tokens)
578
+ const result: Array<string | undefined> = []
579
+ for (let i = 0; i < ghStarts.length; i++) {
580
+ const start = ghStarts[i] as number
581
+ const end = ghStarts[i + 1] ?? tokens.length
582
+ if (!isAuthenticatedUserEndpointArgs(tokens.slice(start + 1, end))) continue
583
+ result.push(effectiveGhTokenForInvocation(tokens, start, env))
584
+ }
585
+ return result
586
+ }
587
+
588
+ export function usesGhApiAuthenticatedUserEndpoint(command: string): boolean {
589
+ return effectiveGhTokensForAuthenticatedUserEndpoint(command, {}).length > 0
590
+ }
591
+
592
+ function isAuthenticatedUserEndpointArgs(args: readonly string[]): boolean {
593
+ if (args[0] !== 'api') return false
594
+ const endpoint = findApiEndpoint(args)
595
+ if (endpoint === null) return false
596
+ const normalized = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint
597
+ return normalized === 'user' || normalized.startsWith('user/')
598
+ }
599
+
600
+ // Walks the contiguous `VAR=val` assignments immediately before `gh` (the same
601
+ // shape findGhInvocations skips) and applies gh's token precedence over env.
602
+ function effectiveGhTokenForInvocation(tokens: readonly string[], ghStart: number, env: GhAuthEnv): string | undefined {
603
+ const local: GhAuthEnv = {}
604
+ for (let i = ghStart - 1; i >= 0 && ENV_ASSIGNMENT_RE.test(tokens[i] as string); i--) {
605
+ const token = tokens[i] as string
606
+ const name = token.slice(0, token.indexOf('='))
607
+ const value = token.slice(token.indexOf('=') + 1)
608
+ // Iterating right-to-left, so only record the first (leftmost wins on dup).
609
+ if (name === 'GH_TOKEN' && local.GH_TOKEN === undefined) local.GH_TOKEN = value
610
+ if (name === 'GITHUB_TOKEN' && local.GITHUB_TOKEN === undefined) local.GITHUB_TOKEN = value
611
+ }
612
+ const ghToken = local.GH_TOKEN ?? env.GH_TOKEN
613
+ if (ghToken !== undefined && ghToken !== '') return ghToken
614
+ return local.GITHUB_TOKEN ?? env.GITHUB_TOKEN
615
+ }
616
+
522
617
  function findGhInvocations(tokens: readonly string[]): number[] {
523
618
  const starts: number[] = []
524
619
  for (let i = 0; i < tokens.length; i++) {
@@ -565,6 +660,29 @@ function extractRepoFlag(args: readonly string[]): string | null {
565
660
  return null
566
661
  }
567
662
 
663
+ // Every valid `-R`/`--repo` slug in `args`, not just the first. The strip removes
664
+ // ALL unquoted repo flags, so the conflict check must see ALL of them: a command
665
+ // like `... -R path/repo -R victim/private` is a mint-for-X-hit-Y attempt where
666
+ // the redundant first flag would otherwise mask the malicious second one.
667
+ function extractAllRepoFlags(args: readonly string[]): string[] {
668
+ const slugs: string[] = []
669
+ for (let i = 0; i < args.length; i++) {
670
+ const arg = args[i]
671
+ if (arg === undefined) continue
672
+ if (arg === '-R' || arg === '--repo') {
673
+ const value = args[i + 1]
674
+ if (value !== undefined && isRepoSlug(value)) slugs.push(value)
675
+ } else if (arg.startsWith('--repo=')) {
676
+ const value = arg.slice('--repo='.length)
677
+ if (isRepoSlug(value)) slugs.push(value)
678
+ } else if (arg.startsWith('-R=')) {
679
+ const value = arg.slice('-R='.length)
680
+ if (isRepoSlug(value)) slugs.push(value)
681
+ }
682
+ }
683
+ return slugs
684
+ }
685
+
568
686
  // `gh api` flags that consume the FOLLOWING token as their value. The endpoint
569
687
  // is the first positional arg that is neither a flag nor a flag's value; only
570
688
  // THAT arg is parsed for owner/repo. Scanning every arg (as before) would let a
@@ -0,0 +1,65 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { chmod, mkdir, rename, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ // A GIT_ASKPASS helper git invokes for username/password prompts. The token
6
+ // rides in TYPECLAW_GIT_TOKEN (env, via the bash env overlay), NEVER in argv or
7
+ // git config — so it cannot leak through process listings, logs, or .git/config.
8
+ // The script contents are constant and secret-free; only the env value is secret.
9
+ //
10
+ // Host-scoped: git's prompt is `Username for 'https://github.com': ` etc. We
11
+ // answer ONLY when the prompt names github.com; for any other host (e.g. one an
12
+ // `insteadOf`/`pushurl` rewrite redirected to) we exit non-zero WITHOUT printing
13
+ // the token, so a redirect can never exfiltrate it. The analyzer already blocks
14
+ // the known redirect vectors; this is defense-in-depth at the credential edge.
15
+ // The host match is on \`//github.com/\` and \`//github.com'\` (git wraps the URL
16
+ // in quotes: \`Password for 'https://github.com': \`) so it cannot be fooled by
17
+ // \`evil-github.com\` or \`github.com.evil/\`.
18
+ const ASKPASS_SCRIPT = `#!/bin/sh
19
+ case "$1" in
20
+ *//github.com/*|*//github.com\\'*) : ;;
21
+ *) exit 1 ;;
22
+ esac
23
+ case "$1" in
24
+ *Username*) printf '%s\\n' 'x-access-token' ;;
25
+ *) printf '%s\\n' "$TYPECLAW_GIT_TOKEN" ;;
26
+ esac
27
+ `
28
+
29
+ // /usr is --ro-bind mounted into the per-tool bwrap sandbox (src/sandbox/build.ts),
30
+ // so a helper here is readable by sandboxed bash; the per-session /tmp bind is not
31
+ // a stable path. TYPECLAW_GIT_ASKPASS_PATH overrides it for tests/CI, which
32
+ // cannot write under /usr.
33
+ const DEFAULT_ASKPASS_PATH = '/usr/local/bin/typeclaw-git-askpass'
34
+
35
+ function defaultPath(): string {
36
+ const override = process.env.TYPECLAW_GIT_ASKPASS_PATH
37
+ return override !== undefined && override !== '' ? override : DEFAULT_ASKPASS_PATH
38
+ }
39
+
40
+ let ensurePromise: Promise<string> | null = null
41
+
42
+ export function resetGitAskPassHelperForTests(): void {
43
+ ensurePromise = null
44
+ }
45
+
46
+ // Writes the helper once per process (idempotent, race-safe via the shared
47
+ // promise) and returns its absolute path. The temp name is unpredictable and
48
+ // opened with `wx` (exclusive create, fails on an existing file/symlink) so a
49
+ // planted symlink cannot redirect the write; then atomically renamed so a
50
+ // concurrent reader never sees a partial file.
51
+ export function ensureGitAskPassHelper(path: string = defaultPath()): Promise<string> {
52
+ if (ensurePromise !== null) return ensurePromise
53
+ ensurePromise = (async () => {
54
+ await mkdir(dirname(path), { recursive: true })
55
+ const tmp = join(dirname(path), `.typeclaw-git-askpass.${randomBytes(8).toString('hex')}.tmp`)
56
+ await writeFile(tmp, ASKPASS_SCRIPT, { mode: 0o755, flag: 'wx' })
57
+ await chmod(tmp, 0o755)
58
+ await rename(tmp, path)
59
+ return path
60
+ })().catch((err) => {
61
+ ensurePromise = null
62
+ throw err
63
+ })
64
+ return ensurePromise
65
+ }