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