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