typeclaw 0.34.1 → 0.35.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 (39) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +71 -13
  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-command.ts +172 -26
  8. package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
  9. package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
  10. package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
  11. package/src/channels/adapters/github/inbound.ts +41 -3
  12. package/src/channels/adapters/slack-bot.ts +17 -9
  13. package/src/channels/continuation-willingness.ts +331 -0
  14. package/src/channels/github-review-claim.ts +105 -0
  15. package/src/channels/github-token-bridge.ts +7 -0
  16. package/src/channels/router.ts +103 -24
  17. package/src/cli/channel.ts +102 -11
  18. package/src/cli/qr.ts +130 -0
  19. package/src/config/config.ts +98 -2
  20. package/src/container/start.ts +12 -0
  21. package/src/init/dockerfile.ts +64 -0
  22. package/src/init/line-auth.ts +8 -3
  23. package/src/inspect/live.ts +128 -13
  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/availability.ts +87 -19
  29. package/src/sandbox/build.ts +27 -0
  30. package/src/sandbox/index.ts +10 -0
  31. package/src/sandbox/package-install.ts +23 -0
  32. package/src/sandbox/policy.ts +31 -0
  33. package/src/sandbox/symlinks.ts +34 -0
  34. package/src/sandbox/writable-zones.ts +164 -4
  35. package/src/server/index.ts +5 -1
  36. package/src/shared/protocol.ts +22 -11
  37. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  38. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  39. package/typeclaw.schema.json +32 -1
@@ -91,33 +91,97 @@ export async function analyzeGitCommand(
91
91
  command: string,
92
92
  options: { cwd: string; resolvers: GitResolvers },
93
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.
94
98
  const stripped = stripSafeCdPrefix(command)
95
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> {
96
172
  const segment = extractSingleGitInvocation(stripped.rest)
97
173
  if (segment === null) return { kind: 'pass-through' }
98
-
99
174
  const args = segment.args
100
175
  const subcommand = findSubcommand(args)
101
176
  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
177
  if (segment.hasLeadingEnvAssignment || hasDangerousGitConfig(args)) {
108
178
  return { kind: 'block', reason: DANGEROUS_CONFIG_REASON }
109
179
  }
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
180
  if ((subcommand === 'fetch' || subcommand === 'pull') && args.includes('--all')) {
113
181
  return { kind: 'block', reason: FETCH_ALL_REASON }
114
182
  }
115
-
116
183
  const dashCDir = extractDashCDir(args)
117
184
  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
185
  let slugs: string[]
122
186
  try {
123
187
  slugs = await resolveRepoSlugs(subcommand, args, effectiveCwd, options.resolvers)
@@ -125,24 +189,15 @@ export async function analyzeGitCommand(
125
189
  return { kind: 'pass-through' }
126
190
  }
127
191
  if (slugs.length === 0) return { kind: 'pass-through' }
128
-
129
192
  const owners = new Set(slugs.map((s) => s.split('/')[0]))
130
193
  if (owners.size > 1) return { kind: 'block', reason: MULTI_OWNER_REASON }
131
-
132
194
  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) }
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 }
143
199
  }
144
- if (containsShellActiveMetachar(command)) return { kind: 'block', reason: COMPOSITION_REASON }
145
- return { kind: 'inject', repoSlug }
200
+ return { kind: 'inject', repoSlug, rewrittenCommand: rewriteCdToDashC(effectiveCwd, stripped.rest) }
146
201
  }
147
202
 
148
203
  // Global flags that can redirect git's auth or destination. `-c`/`--config-env`
@@ -358,6 +413,97 @@ function extractSingleGitInvocation(command: string): GitInvocation | null {
358
413
  return { args, hasLeadingEnvAssignment }
359
414
  }
360
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
+
361
507
  function isCommandBoundaryBefore(tokens: readonly string[], index: number): boolean {
362
508
  let cursor = index - 1
363
509
  while (cursor >= 0) {
@@ -3,16 +3,28 @@ import { definePlugin } from '@/plugin'
3
3
 
4
4
  import { createApproveIdempotencyGuard } from './approve-idempotency'
5
5
  import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
6
- import { analyzeGhCommand } from './gh-command'
6
+ import { analyzeGhCommand, effectiveGhTokensForAuthenticatedUserEndpoint } from './gh-command'
7
7
  import { ensureGitAskPassHelper } from './git-askpass'
8
8
  import { analyzeGitCommand, defaultGitResolvers } from './git-command'
9
9
  import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
10
10
  import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
11
- import { classifyGhToken } from './token-class'
11
+ import { classifyGhToken, shouldMintAppToken } from './token-class'
12
12
 
13
13
  export default definePlugin({
14
14
  plugin: async (ctx) => {
15
15
  const resolveTokenForRepo = ctx.github.resolveTokenForRepo
16
+ const hasAppTokenResolver = ctx.github.hasAppTokenResolver
17
+ // `/user` resolves the caller's USER identity. An App installation token is not
18
+ // a user, so GitHub rejects it on a token-class basis (403, or no-token error in
19
+ // the sandbox) no matter how valid the token is. We block-and-guide so the agent
20
+ // does not misread this as "I have no auth" — it does, for repo-scoped calls.
21
+ const appUserEndpointReason =
22
+ '`gh api /user` (and `/user/...`) resolves the calling USER. This agent authenticates ' +
23
+ 'as a GitHub App with a per-repo installation token, which is not a user identity — so ' +
24
+ '`/user` cannot work here, and this failure is NOT a sign that auth is missing (repo-' +
25
+ 'scoped calls still work). It is not a valid auth/login check. For repo data use ' +
26
+ '`gh <cmd> -R owner/repo` or `gh api /repos/owner/repo/...`; for the actor, read the ' +
27
+ 'PR/issue/comment context you were given instead of `gh api /user`.'
16
28
  const resolveToken = async (workspace: string) => {
17
29
  const result = await resolveTokenForRepo(workspace)
18
30
  return result.kind === 'token' ? result.token : null
@@ -44,8 +56,32 @@ export default definePlugin({
44
56
  if (review.dump !== null) return review.dump
45
57
 
46
58
  const decision = analyzeGhCommand(command)
59
+
60
+ // `/user` classifies as pass-through (no repo to mint for), so this block
61
+ // must run BEFORE the pass-through return. Resolve the EFFECTIVE token per
62
+ // `/user` invocation (a command-local `GH_TOKEN=…`/`GITHUB_TOKEN=…` overrides
63
+ // process env, matching gh) and block only when that token is App / none-with-
64
+ // minter — a command-local PAT override carries a user identity, so `/user`
65
+ // works for it and must not be blocked.
66
+ const userEndpointTokens = effectiveGhTokensForAuthenticatedUserEndpoint(command, {
67
+ GH_TOKEN: process.env.GH_TOKEN,
68
+ GITHUB_TOKEN: process.env.GITHUB_TOKEN,
69
+ })
70
+ if (userEndpointTokens.some((token) => shouldMintAppToken(token, hasAppTokenResolver()))) {
71
+ return { block: true, reason: appUserEndpointReason }
72
+ }
73
+
47
74
  if (decision.kind === 'pass-through') return 'fall-through'
48
75
 
76
+ // The `-R` strip is a pure syntax fix (`gh api` rejects `-R`), independent
77
+ // of token minting, so apply it for EVERY token class — including the PAT
78
+ // paths below that return without injecting. Only `inject` decisions carry
79
+ // `rewrittenCommand`, and only after the single-bare/safe-pipeline gate in
80
+ // analyzeGhCommand, so this never rewrites a blocked or unsafe shape.
81
+ if (decision.kind === 'inject' && decision.rewrittenCommand !== undefined) {
82
+ event.args.command = decision.rewrittenCommand
83
+ }
84
+
49
85
  const tokenClass = classifyGhToken(process.env.GH_TOKEN)
50
86
  // Classic PATs reach every owner; nothing to inject or enforce.
51
87
  if (tokenClass === 'cross-owner') return
@@ -57,15 +93,16 @@ export default definePlugin({
57
93
  // `gh` fails honestly if the named repo is under a different owner.
58
94
  if (tokenClass === 'fine-grained-pat') return
59
95
 
96
+ // No App auth (no App-class GH_TOKEN and no live minter): leave whatever
97
+ // is seeded so `gh` fails honestly rather than us guessing a token.
98
+ if (!shouldMintAppToken(process.env.GH_TOKEN, hasAppTokenResolver())) return
99
+
60
100
  const result = await resolveTokenForRepo(decision.repoSlug)
61
101
  if (result.kind === 'unavailable') return { block: true, reason: result.reason }
62
102
  // Inject via the internal env overlay (delivered to the spawn / bwrap
63
103
  // --setenv by the bash wrapper) so the token never enters the command
64
104
  // string, where it could leak through logs or later hooks.
65
105
  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
106
  return
70
107
  }
71
108
 
@@ -76,8 +113,10 @@ export default definePlugin({
76
113
  }): Promise<HookResult> => {
77
114
  const { event, command, agentDir } = params
78
115
  // 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
116
+ // tokens are left untouched, exactly as the gh path treats them. App auth
117
+ // is detected by the live minter too, not just an App-class GH_TOKEN:
118
+ // multi-owner / no-repos App configs never seed GH_TOKEN yet can mint.
119
+ if (!shouldMintAppToken(process.env.GH_TOKEN, hasAppTokenResolver())) return
81
120
 
82
121
  const decision = await analyzeGitCommand(command, { cwd: agentDir, resolvers: defaultGitResolvers })
83
122
  if (decision.kind === 'pass-through') return
@@ -9,3 +9,16 @@ export function classifyGhToken(token: string | undefined): GhTokenClass {
9
9
  // a per-repo token rather than silently using a possibly-wrong global one.
10
10
  return 'app'
11
11
  }
12
+
13
+ // Whether the per-repo App minter should fire for a repo-targeting command.
14
+ // App auth is detected via EITHER a seeded App-class GH_TOKEN OR a live App
15
+ // token resolver — the latter is the authority because multi-owner / no-repos
16
+ // App configs intentionally leave GH_TOKEN unseeded (the prefix would read
17
+ // 'none'), yet the per-repo minter is still registered and able to mint. Classic
18
+ // and fine-grained PATs are never re-minted: they pass through with whatever
19
+ // GH_TOKEN is seeded, exactly as before.
20
+ export function shouldMintAppToken(token: string | undefined, hasAppTokenResolver: boolean): boolean {
21
+ const tokenClass = classifyGhToken(token)
22
+ if (tokenClass === 'cross-owner' || tokenClass === 'fine-grained-pat') return false
23
+ return tokenClass === 'app' || hasAppTokenResolver
24
+ }
@@ -427,6 +427,37 @@ const GIT_EXFIL_VERBS = [
427
427
  'hub\\s+(?:create|push)',
428
428
  ].join('|')
429
429
 
430
+ // "backup" framing across the same major-language set the rest of this file
431
+ // covers. The narrow English+Korean-only version let "백업 해줘 to my repo"
432
+ // framings phrased in any other channel language slip the standalone-backup
433
+ // catch (the SECRET_DEMAND patterns still fire when a credential is named; this
434
+ // is only the no-secret-named "push to my backup repo" idiom). Entries are the
435
+ // noun/verb for "backup"/"back up" — kept tight so they only matter within the
436
+ // 80-char proximity-to-git-push window below, never on their own.
437
+ const BACKUP_NOUNS = [
438
+ 'backup',
439
+ 'back[-\\s]?up',
440
+ '\u{BC31}\u{C5C5}', // ko 백업
441
+ '\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7', // ja バックアップ
442
+ '\u5907\u4EFD', // zh-hans 备份
443
+ '\u5099\u4EFD', // zh-hant 備份
444
+ 'copia\\s*de\\s*seguridad', // es
445
+ 'respald(?:o|ar|a)', // es respaldo/respaldar
446
+ 'sauvegard(?:e|er)', // fr
447
+ 'sicherungskopie', // de
448
+ 'sicher(?:n|ung)', // de Sicherung/sichern
449
+ 'c[\u00F3o]pia\\s*de\\s*seguran[\u00E7c]a', // pt
450
+ 'fazer\\s*backup', // pt
451
+ '\u0440\u0435\u0437\u0435\u0440\u0432\u043D(?:\u0430\u044F|\u0443\u044E)\\s*\u043A\u043E\u043F\u0438\u044F', // ru резервная копия
452
+ 'sao\\s*l\u01B0u', // vi sao lưu
453
+ 'cadang(?:an)?', // id cadangan/mencadangkan
454
+ '\u0646\u0633\u062E(?:\u0629)?\\s*\u0627\u062D\u062A\u064A\u0627\u0637\u064A(?:\u0629)?', // ar نسخة احتياطية
455
+ '\u092C\u0948\u0915\u0905\u092A', // hi बैकअप
456
+ 'yede(?:k|kle)', // tr yedek/yedekle
457
+ 'copia\\s*di\\s*sicurezza', // it
458
+ 'backup', // it (loanword, same token)
459
+ ].join('|')
460
+
430
461
  const GIT_EXFIL_PATTERNS: ReadonlyArray<RegExp> = [
431
462
  new RegExp(`(?:${GIT_EXFIL_VERBS})`, 'i'),
432
463
  // Urgency shorthand ("do it" / "go ahead" / "now") right after a git command,
@@ -440,8 +471,8 @@ const GIT_EXFIL_PATTERNS: ReadonlyArray<RegExp> = [
440
471
  // request - if the same message also names a credential or `.env`, the
441
472
  // SECRET_DEMAND_PATTERNS already fires; this catches the standalone
442
473
  // "push to my backup repo" framing that doesn't mention secrets.
443
- /(?:backup|back[-\s]?up|\u{BC31}\u{C5C5})[\s\S]{0,80}(?:git\s+push|github\.com|gitlab\.com|bitbucket\.org)/iu,
444
- /(?:git\s+push|github\.com|gitlab\.com|bitbucket\.org)[\s\S]{0,80}(?:backup|back[-\s]?up|\u{BC31}\u{C5C5})/iu,
474
+ new RegExp(`(?:${BACKUP_NOUNS})[\\s\\S]{0,80}(?:git\\s+push|github\\.com|gitlab\\.com|bitbucket\\.org)`, 'iu'),
475
+ new RegExp(`(?:git\\s+push|github\\.com|gitlab\\.com|bitbucket\\.org)[\\s\\S]{0,80}(?:${BACKUP_NOUNS})`, 'iu'),
445
476
  ]
446
477
 
447
478
  export type InjectionMatch = {
@@ -396,17 +396,24 @@ export function classifyGithubInbound(
396
396
  const root = parentId ?? id
397
397
  const parent =
398
398
  parentId !== null && options?.reviewCommentParent?.parentId === parentId ? options.reviewCommentParent : null
399
+ const commenter = readUser(comment.user)
400
+ const directedAtBot =
401
+ parentId === null &&
402
+ isSelfPr(readUser(pr.user), selfLogin, options?.authType ?? 'pat') &&
403
+ commenter !== null &&
404
+ !isSelfAuthor(commenter, null, selfLogin)
399
405
  return buildInbound(
400
406
  { ...base, chat: `pr:${number}`, thread: String(root) },
401
407
  comment.body,
402
408
  id,
403
- readUser(comment.user),
409
+ commenter,
404
410
  mention,
405
411
  comment.created_at,
406
412
  { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
407
413
  false,
408
414
  {
409
415
  suppressSticky: true,
416
+ ...(directedAtBot ? { forceBotMention: true } : {}),
410
417
  replyToBotMessageId: parent?.isSelf === true ? String(parent.parentId) : null,
411
418
  replyToOtherMessageId: parent?.isSelf === false ? String(parent.parentId) : null,
412
419
  },
@@ -533,6 +540,11 @@ export function classifyGithubInbound(
533
540
  : reviewer !== null
534
541
  ? synthesizeReviewStateText(reviewer.login, number, readString(pr, 'title'), readString(review, 'state'))
535
542
  : ''
543
+ const directedAtBot =
544
+ isSelfPr(readUser(pr.user), selfLogin, options?.authType ?? 'pat') &&
545
+ reviewer !== null &&
546
+ !isSelfAuthor(reviewer, null, selfLogin) &&
547
+ isActionableReviewState(readString(review, 'state'))
536
548
  return buildInbound(
537
549
  { ...base, chat: `pr:${number}`, thread: null },
538
550
  text,
@@ -542,7 +554,7 @@ export function classifyGithubInbound(
542
554
  review.submitted_at,
543
555
  null,
544
556
  !hasBody,
545
- { suppressSticky: true },
557
+ { suppressSticky: true, ...(directedAtBot ? { forceBotMention: true } : {}) },
546
558
  )
547
559
  }
548
560
 
@@ -591,6 +603,12 @@ type BuildInboundOptions = {
591
603
  suppressSticky?: boolean
592
604
  replyToBotMessageId?: string | null
593
605
  replyToOtherMessageId?: string | null
606
+ // Forces isBotMention=true with no @-handle in the body. A review (or
607
+ // top-level review comment) on a PR the agent ITSELF authored is directed at
608
+ // the bot — the inverse of review_requested — so it engages even though the
609
+ // reviewer never types `@bot`. Paired with suppressSticky so reviews on OTHER
610
+ // people's PRs still observe-only (preserving the PR #672 fix).
611
+ forceBotMention?: boolean
594
612
  }
595
613
 
596
614
  // A GitHub App can never be a `requested_reviewer` — that field only holds
@@ -799,7 +817,7 @@ function buildInbound(
799
817
  // Synthesized awareness lines carry an `@author` prefix describing who acted;
800
818
  // that handle is the author, never a third-party mention of the bot, so the
801
819
  // body-text mention heuristic must not fire on it.
802
- const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
820
+ const isBotMention = options?.forceBotMention === true || (!synthesizedAwareness && textMentionsBot(text, mention))
803
821
  const replyToBotMessageId = options?.replyToBotMessageId ?? null
804
822
  const replyToOtherMessageId = options?.replyToOtherMessageId ?? key.replyToOtherMessageId
805
823
  return {
@@ -854,6 +872,15 @@ function synthesizeReviewStateText(
854
872
  return `@${reviewer} ${verb} PR #${number}${label}.`
855
873
  }
856
874
 
875
+ // A review on the agent's own PR engages it only when there is something to act
876
+ // on. APPROVED clears the PR and needs no reply, so engaging would just produce
877
+ // "thanks" churn; it stays awareness-only. COMMENTED and CHANGES_REQUESTED (and
878
+ // any non-approval state) carry feedback the agent should address. State case
879
+ // varies by payload source (webhook vs REST), so normalize before matching.
880
+ function isActionableReviewState(state: string | null): boolean {
881
+ return state?.toLowerCase() !== 'approved'
882
+ }
883
+
857
884
  async function resolveTeamMembership(
858
885
  event: string,
859
886
  payload: Record<string, unknown>,
@@ -981,6 +1008,17 @@ function isSelfAuthor(author: GithubUser, selfId: string | null, selfLogin: stri
981
1008
  return false
982
1009
  }
983
1010
 
1011
+ // Whether the PR's OPENER is this agent. Distinct from isSelfAuthor (which
1012
+ // guards self-loops on the event ACTOR by id/login): here we have only the
1013
+ // `pull_request.user` from a review payload, no id to compare, and under App
1014
+ // auth the bot opens PRs as the decoy account (login = slug, e.g. `typeey`),
1015
+ // not the actor login `typeey[bot]`. So this matches selfLogin AND the decoy
1016
+ // slug — mirroring resolveBotMentionLogins.
1017
+ function isSelfPr(prUser: GithubUser | null, selfLogin: string | null, authType: 'pat' | 'app'): boolean {
1018
+ if (prUser === null || selfLogin === null) return false
1019
+ return resolveBotMentionLogins(selfLogin, authType).includes(prUser.login)
1020
+ }
1021
+
984
1022
  type GithubUser = { login: string; id: number; type?: string }
985
1023
 
986
1024
  function readUser(value: unknown): GithubUser | null {
@@ -359,18 +359,26 @@ export function createTypingCallback(deps: {
359
359
  // threads keep using `thread`. Either way the status is keyed on one ts.
360
360
  const statusThread =
361
361
  target.typingThread !== undefined && target.typingThread !== '' ? target.typingThread : target.thread
362
- const tag = formatChannelTag
363
- ? await formatChannelTag(target.workspace, statusThread ?? target.chat)
364
- : `channel=${statusThread ?? target.chat}`
365
362
  if (statusThread === undefined || statusThread === null || statusThread === '') {
366
- if (target.phase === 'tick') logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
367
- return
368
- }
369
- if (target.phase === 'stop') {
370
- await typingTracker.clearAfterSend(target.chat, statusThread)
363
+ if (target.phase === 'tick') {
364
+ const tag = formatChannelTag
365
+ ? await formatChannelTag(target.workspace, statusThread ?? target.chat)
366
+ : `channel=${statusThread ?? target.chat}`
367
+ logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
368
+ }
371
369
  return
372
370
  }
373
- await typingTracker.setStatus(target.chat, statusThread, 'is typing...')
371
+ // Append to the per-(chat,thread) FIFO BEFORE awaiting anything: the FIFO
372
+ // only orders calls once setStatus/clearAfterSend is reached, so awaiting
373
+ // `formatChannelTag` first opens an unordered gap where a fire-and-forget
374
+ // re-arm 'tick' (router send() after a reply) can enqueue "is typing..."
375
+ // AFTER the turn-end 'stop' clear. Flat DMs have no threaded-reply
376
+ // auto-clear, so that strands the indicator until Slack's ~2-min timeout.
377
+ const enqueued =
378
+ target.phase === 'stop'
379
+ ? typingTracker.clearAfterSend(target.chat, statusThread)
380
+ : typingTracker.setStatus(target.chat, statusThread, 'is typing...')
381
+ await enqueued
374
382
  }
375
383
  }
376
384