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.
- package/package.json +3 -1
- package/src/agent/plugin-tools.ts +71 -13
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/inspect/live.ts +128 -13
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/availability.ts +87 -19
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/server/index.ts +5 -1
- package/src/shared/protocol.ts +22 -11
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- 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
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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')
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|