@rmyndharis/aimhooman 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.agents/rules/aimhooman.md +53 -0
  2. package/.claude-plugin/marketplace.json +23 -0
  3. package/.claude-plugin/plugin.json +9 -0
  4. package/.clinerules/aimhooman.md +53 -0
  5. package/.codex-plugin/plugin.json +37 -0
  6. package/.cursor/rules/aimhooman.mdc +59 -0
  7. package/.gemini/settings.json +7 -0
  8. package/.github/copilot-instructions.md +53 -0
  9. package/.github/hooks/aimhooman.json +22 -0
  10. package/.kiro/steering/aimhooman.md +53 -0
  11. package/.windsurf/rules/aimhooman.md +57 -0
  12. package/AGENTS.md +53 -0
  13. package/CHANGELOG.md +153 -0
  14. package/CODE_OF_CONDUCT.md +128 -0
  15. package/CONTRIBUTING.md +144 -0
  16. package/GEMINI.md +53 -0
  17. package/LICENSE +21 -0
  18. package/README.md +472 -0
  19. package/SECURITY.md +74 -0
  20. package/bin/aimhooman.mjs +1589 -0
  21. package/docs/design/agent-portability.md +73 -0
  22. package/docs/design/frictionless-enforcement.md +135 -0
  23. package/docs/hosts.json +164 -0
  24. package/docs/logo/aimhooman-logo.png +0 -0
  25. package/docs/logo/aimhooman.png +0 -0
  26. package/hooks/hooks.json +29 -0
  27. package/package.json +77 -0
  28. package/rules/attribution.json +142 -0
  29. package/rules/markers.json +88 -0
  30. package/rules/paths.json +407 -0
  31. package/rules/secrets.json +90 -0
  32. package/schemas/overrides.schema.json +134 -0
  33. package/schemas/project-policy.schema.json +12 -0
  34. package/schemas/rule-pack.schema.json +126 -0
  35. package/schemas/scan-report.schema.json +165 -0
  36. package/skills/aimhooman/SKILL.md +65 -0
  37. package/src/args.mjs +72 -0
  38. package/src/atomic-write.mjs +285 -0
  39. package/src/codex-manifest.mjs +65 -0
  40. package/src/exclude.mjs +125 -0
  41. package/src/git-environment.mjs +5 -0
  42. package/src/git-path.mjs +5 -0
  43. package/src/githooks.mjs +813 -0
  44. package/src/gitx.mjs +721 -0
  45. package/src/history-scan.mjs +295 -0
  46. package/src/hook.mjs +2602 -0
  47. package/src/policy-resolver.mjs +200 -0
  48. package/src/report.mjs +63 -0
  49. package/src/rules.mjs +509 -0
  50. package/src/ruleset-text.mjs +29 -0
  51. package/src/scan-session.mjs +152 -0
  52. package/src/scan-target.mjs +697 -0
  53. package/src/scan.mjs +325 -0
  54. package/src/state.mjs +360 -0
package/src/hook.mjs ADDED
@@ -0,0 +1,2602 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { newEngineWithDiagnostics } from './scan.mjs';
7
+ import { openRepo, stagedEntries } from './gitx.mjs';
8
+ import { applyExclude, patternsForRules } from './exclude.mjs';
9
+ import { activeGitHook, installedHooks } from './githooks.mjs';
10
+ import { visible } from './report.mjs';
11
+ import { extractRuleset } from './ruleset-text.mjs';
12
+ import { resolvePolicy } from './policy-resolver.mjs';
13
+ import { engineForPolicy, resolveStagedPolicy } from './scan-target.mjs';
14
+ import { scanEntries } from './scan-session.mjs';
15
+
16
+ const AGENTS_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'AGENTS.md');
17
+ const AMBIGUOUS_PATH_REASON =
18
+ 'aimhooman cannot map a POSIX-root or tilde target path to the repository that the shell will use; pass an expanded native absolute path or run the Git command from that repository.';
19
+ const UNCERTAIN_TARGET_REASON =
20
+ 'aimhooman cannot determine the Git operation or repository target after shell expansion or a dynamic directory change; pass a literal absolute path and run the Git operation separately.';
21
+
22
+ // asObject returns v only when it is a plain object; otherwise {}.
23
+ // Guards against hostile/malformed hook payloads: JSON.parse('null') yields
24
+ // null, and `null.cwd` would throw, crashing the hook.
25
+ export function asObject(v) {
26
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
27
+ }
28
+
29
+ function ruleset() {
30
+ try {
31
+ return extractRuleset(readFileSync(AGENTS_PATH, 'utf8'), AGENTS_PATH);
32
+ } catch {
33
+ return 'This repository uses aimhooman: never commit AI session files, secrets, or AI attribution. AI works, hoomans ship.';
34
+ }
35
+ }
36
+
37
+ function enforcementPolicy(repo) {
38
+ const { policy, head } = resolveStagedPolicy(repo);
39
+ return { ...policy, head };
40
+ }
41
+
42
+ // runHook implements `aimhooman hook <event>` for AI coding tool adapters.
43
+ export async function runHook(args) {
44
+ const event = args[0];
45
+ let input = {};
46
+ let inputError = '';
47
+ try {
48
+ const raw = await readStdin();
49
+ if (!raw.trim()) {
50
+ inputError = 'the hook payload was empty';
51
+ } else {
52
+ const parsed = JSON.parse(raw);
53
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
54
+ inputError = 'the hook payload must be a JSON object';
55
+ } else {
56
+ input = parsed;
57
+ }
58
+ }
59
+ } catch (error) {
60
+ inputError = `the hook payload is not valid JSON (${error.message})`;
61
+ input = {};
62
+ }
63
+ if (event === 'pre-tool-use' && inputError) {
64
+ return emitDecision(
65
+ 'deny',
66
+ `aimhooman cannot inspect this tool call because ${inputError}; retry the command so the host sends a complete JSON payload.`
67
+ );
68
+ }
69
+ if (typeof input.cwd === 'string') {
70
+ try {
71
+ process.chdir(input.cwd);
72
+ } catch {
73
+ /* best effort */
74
+ }
75
+ }
76
+ if (event === 'session-start') return hookSessionStart();
77
+ if (event === 'pre-tool-use') return hookPreToolUse(input);
78
+ return 0;
79
+ }
80
+
81
+ export function hookSessionStart() {
82
+ try {
83
+ const repo = openRepo();
84
+ const policy = resolvePolicy(repo, { target: 'worktree' });
85
+ const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
86
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
87
+ } catch {
88
+ /* not a repo; nothing to exclude */
89
+ }
90
+ const ctx = ruleset();
91
+ emit({
92
+ additionalContext: ctx,
93
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: ctx },
94
+ });
95
+ return 0;
96
+ }
97
+
98
+ function hookPreToolUse(input) {
99
+ const executor = classifyExecutor(toolName(input));
100
+ if (!executor) return 0;
101
+ const executorCommand = command(input, executor);
102
+ if (executorCommand === null) return unknownExecutorShape(input, executor.name);
103
+ const rawParsed = parseGit(executorCommand);
104
+ if (nonPosixExecutor(executor.name)) rawParsed.uncertainShell = true;
105
+ if (executorTargetSyntaxUncertain(executor.name, executorCommand, rawParsed)
106
+ && (rawParsed.addPaths.length > 0 || rawParsed.commands.some(isProtectedMutation))) {
107
+ return emitDecision(
108
+ 'deny',
109
+ 'aimhooman cannot prove a repository target selected with non-POSIX shell syntax; use a direct Git command from that repository.',
110
+ );
111
+ }
112
+ if (rawParsed.commands.some((candidate) => (
113
+ isGuardedCandidate(candidate) && candidate.pathDialectUncertain
114
+ ))) {
115
+ return emitDecision(
116
+ 'deny',
117
+ AMBIGUOUS_PATH_REASON,
118
+ );
119
+ }
120
+ const rawTargetEnvironment = rawParsed.commands.find((candidate) => (
121
+ isGuardedCandidate(candidate) && candidate.targetEnvironmentRisk
122
+ ));
123
+ if (rawTargetEnvironment) {
124
+ return emitDecision(
125
+ 'deny',
126
+ `aimhooman cannot verify which repository policy applies with runtime or Git target environment assignments `
127
+ + `(${(rawTargetEnvironment.environmentRisk || []).join(', ')}); run the Git command without those assignments.`,
128
+ );
129
+ }
130
+ if (rawParsed.commands.some((candidate) => (
131
+ isGuardedCandidate(candidate) && candidate.targetUncertain
132
+ ))) {
133
+ return emitDecision(
134
+ 'deny',
135
+ UNCERTAIN_TARGET_REASON,
136
+ );
137
+ }
138
+ const parsed = resolveGitAliases(rawParsed);
139
+ const { commit, noVerify, addPaths } = parsed;
140
+ const bypassHooks = parsed.bypassHooks;
141
+ const protectedMutation = parsed.commands.some(isProtectedMutation);
142
+ if (!commit && !protectedMutation && addPaths.length === 0 && !parsed.uncertainShell) return 0;
143
+ if (parsed.commands.some((candidate) => (
144
+ isGuardedCandidate(candidate) && candidate.pathDialectUncertain
145
+ ))) {
146
+ return emitDecision(
147
+ 'deny',
148
+ AMBIGUOUS_PATH_REASON,
149
+ );
150
+ }
151
+ const targetEnvironment = parsed.commands.find((candidate) => (
152
+ isGuardedCandidate(candidate) && candidate.targetEnvironmentRisk
153
+ ));
154
+ if (targetEnvironment) {
155
+ return emitDecision(
156
+ 'deny',
157
+ `aimhooman cannot verify which repository policy applies with runtime or Git target environment assignments `
158
+ + `(${(targetEnvironment.environmentRisk || []).join(', ')}); run the Git command without those assignments.`,
159
+ );
160
+ }
161
+ if (parsed.commands.some((candidate) => (
162
+ isGuardedCandidate(candidate) && candidate.targetUncertain
163
+ ))) return emitDecision('deny', UNCERTAIN_TARGET_REASON);
164
+
165
+ let repo = null;
166
+ try {
167
+ const primary = parsed.commands.find((candidate) => candidate.verb === 'commit')
168
+ || parsed.commands.find((candidate) => candidate.verb === 'unknown')
169
+ || parsed.commands.find(isProtectedMutation)
170
+ || parsed.commands.find((candidate) => candidate.verb === 'add');
171
+ repo = openRepo(primary?.cwd || process.cwd());
172
+ } catch {
173
+ repo = null;
174
+ }
175
+ let profile = 'clean';
176
+ let policy = null;
177
+ if (repo) {
178
+ try {
179
+ policy = enforcementPolicy(repo);
180
+ profile = policy.profile;
181
+ } catch (e) {
182
+ return emitDecision('deny', `aimhooman cannot load project policy: ${e.message}`);
183
+ }
184
+ }
185
+
186
+ // A command the parser flagged as uncertain shell syntax (nesting, a
187
+ // pipeline, a background job, executable indirection, or an unresolved
188
+ // wrapper) is denied under strict even when no commit was recognized,
189
+ // because a hidden commit + --no-verify could not be safely excluded. This
190
+ // only needs to catch commands the per-command loop below cannot reach
191
+ // (empty commands); when commands are present, that loop emits the more
192
+ // specific denial reason.
193
+ if (profile === 'strict' && parsed.uncertainShell && parsed.commands.length === 0) {
194
+ return emitDecision(
195
+ 'deny',
196
+ 'aimhooman cannot safely verify this command under the strict profile; ' +
197
+ 'it uses shell nesting, a pipeline, a background job, or indirection the guard cannot fully resolve. ' +
198
+ 'Run the Git commit as a direct, supported command.'
199
+ );
200
+ }
201
+
202
+ // Evaluate bypass/future-index risk against each command's actual `git -C`
203
+ // repository, not merely the host tool's initial cwd.
204
+ for (const gitCommand of parsed.commands.filter(isProtectedMutation)) {
205
+ let targetRepo;
206
+ try {
207
+ if (gitCommand.environmentRisk?.includes('GIT_INDEX_FILE')) {
208
+ return emitDecision(
209
+ 'deny',
210
+ 'aimhooman cannot verify the exact staged snapshot when GIT_INDEX_FILE selects another index; run the Git command without that assignment.'
211
+ );
212
+ }
213
+ if (gitCommand.targetEnvironmentRisk) {
214
+ return emitDecision(
215
+ 'deny',
216
+ `aimhooman cannot verify which repository policy applies with runtime or Git target environment assignments ` +
217
+ `(${gitCommand.environmentRisk.join(', ')}); run the Git command without those assignments.`
218
+ );
219
+ }
220
+ if (gitCommand.targetUncertain) {
221
+ return emitDecision(
222
+ 'deny',
223
+ 'aimhooman cannot determine the policy that will apply after dynamic execution; run the Git commit separately.'
224
+ );
225
+ }
226
+ if (gitCommand.policyTransitionRisk) {
227
+ return emitDecision(
228
+ 'deny',
229
+ 'aimhooman cannot determine the policy after an earlier Git state change; run the Git commit separately.'
230
+ );
231
+ }
232
+ targetRepo = openRepo(gitCommand.cwd);
233
+ const targetProfile = enforcementPolicy(targetRepo).profile;
234
+ // An unresolved subcommand/alias may itself move a ref. When its
235
+ // hook path or execution context is altered, there is no safe
236
+ // content snapshot to fall back to, so treat it like a direct ref
237
+ // mutation in every profile.
238
+ const directRefMutation = gitCommand.verb !== 'commit';
239
+ if (gitCommand.verb === 'unknown') {
240
+ const label = gitCommand.inlineAliasRisk
241
+ ? 'an inline Git alias whose expansion is not part of the inspected command'
242
+ : `Git subcommand or alias "${gitCommand.subcommand}"`;
243
+ return emitDecision(
244
+ 'deny',
245
+ `aimhooman cannot prove that ${label} preserves the managed reference-transaction guard; `
246
+ + 'run a direct supported Git command.'
247
+ );
248
+ }
249
+ if (gitCommand.verb === 'push' && configuredPushReceiver(targetRepo)) {
250
+ return emitDecision(
251
+ 'deny',
252
+ 'aimhooman cannot prove that a configured remote receive-pack preserves the managed reference-transaction guard; remove remote.*.receivepack or run a direct push without receiver indirection.'
253
+ );
254
+ }
255
+ if (directRefMutation && (
256
+ parsed.uncertainShell
257
+ || gitCommand.classification === 'uncertain'
258
+ || gitCommand.prefixRisk
259
+ || gitCommand.bypassHooks
260
+ || gitCommand.environmentRisk?.length
261
+ )) {
262
+ return emitDecision(
263
+ 'deny',
264
+ `aimhooman cannot verify the final reference update for git ${gitCommand.verb} `
265
+ + 'after hook, environment, shell, or prefix indirection; run the Git command directly with the managed reference-transaction hook.'
266
+ );
267
+ }
268
+ const activeHooks = installedHooks(targetRepo);
269
+ const requiredHooks = gitCommand.verb === 'commit'
270
+ ? ['pre-commit', 'commit-msg', 'reference-transaction']
271
+ : ['reference-transaction'];
272
+ const missingHooks = requiredHooks
273
+ .filter((hook) => !activeHooks.includes(hook));
274
+ if (missingHooks.length) {
275
+ return emitDecision(
276
+ 'deny',
277
+ `aimhooman requires current, reachable ${requiredHooks.join(', ')} guards before this Git operation; ` +
278
+ `${missingHooks.join(' and ')} ${missingHooks.length === 1 ? 'is' : 'are'} unavailable. ` +
279
+ "Run 'aimhooman init' and retry."
280
+ );
281
+ }
282
+ if (targetProfile !== 'strict') continue;
283
+ if (parsed.uncertainShell || gitCommand.classification === 'uncertain') {
284
+ return emitDecision(
285
+ 'deny',
286
+ 'aimhooman cannot safely verify a strict Git commit inside shell nesting, a pipeline, a background job, or executable indirection; run the Git command directly.'
287
+ );
288
+ }
289
+ if (gitCommand.prefixRisk) {
290
+ return emitDecision(
291
+ 'deny',
292
+ 'aimhooman cannot verify strict commit guards after an earlier command may have changed the repository or its hooks; run the Git commit separately.'
293
+ );
294
+ }
295
+ if (gitCommand.environmentRisk?.length) {
296
+ return emitDecision(
297
+ 'deny',
298
+ `aimhooman strict profile cannot verify a commit with runtime or Git target environment assignments ` +
299
+ `(${gitCommand.environmentRisk.join(', ')}); run the Git command without those assignments.`
300
+ );
301
+ }
302
+ if (gitCommand.noVerify || gitCommand.bypassHooks) {
303
+ return emitDecision(
304
+ 'deny',
305
+ 'aimhooman strict profile forbids bypassing repository policy hooks (--no-verify/-n or core.hooksPath override).'
306
+ );
307
+ }
308
+ if (gitCommand.verb === 'commit') {
309
+ const prepareMessageHook = activeGitHook(targetRepo, 'prepare-commit-msg');
310
+ if (prepareMessageHook.active) {
311
+ return emitDecision(
312
+ 'deny',
313
+ 'aimhooman cannot verify a strict agent commit while an active prepare-commit-msg hook can change the final message guard; remove or integrate that hook before retrying.'
314
+ );
315
+ }
316
+ if (gitCommand.editorRisk) {
317
+ return emitDecision(
318
+ 'deny',
319
+ 'aimhooman cannot verify a strict agent commit that opens a local editor after pre-commit; provide the message with -m, -F, -C, or --no-edit.'
320
+ );
321
+ }
322
+ }
323
+ } catch (e) {
324
+ return emitDecision('deny', `aimhooman cannot verify target repository policy and guards: ${e.message}`);
325
+ }
326
+ }
327
+ let eng;
328
+ let diagnosticWarning = '';
329
+ try {
330
+ const loaded = repo
331
+ ? engineForPolicy(repo, policy, policy.head)
332
+ : newEngineWithDiagnostics(profile);
333
+ eng = loaded.engine;
334
+ const errors = loaded.errors || loaded.diagnostics || [];
335
+ if (errors.length) {
336
+ diagnosticWarning = errors.map((error) => error.message || String(error)).join('; ');
337
+ if (profile === 'strict') {
338
+ return emitDecision('deny', `aimhooman strict policy could not load local rules: ${diagnosticWarning}`);
339
+ }
340
+ }
341
+ if (repo) applyExclude(repo.excludeFile, patternsForRules(eng.rules));
342
+ } catch (e) {
343
+ const reason = e?.name === 'LocalOverridesError'
344
+ ? `aimhooman cannot load local overrides: ${e.message}`
345
+ : `aimhooman could not load policy rules: ${e.message}`;
346
+ if (e?.name === 'LocalOverridesError') return emitDecision('deny', reason);
347
+ if (profile === 'strict') return emitDecision('deny', reason);
348
+ return emitDecision('allow', `${reason}; continuing because profile=${profile}`);
349
+ }
350
+ // A strict policy cannot make a meaningful guarantee if Git's own guards
351
+ // are explicitly bypassed. Deny before the shell can stage-and-commit in a
352
+ // single command, when inspecting the future index would be impossible.
353
+ if (profile === 'strict' && commit && (noVerify || bypassHooks)) {
354
+ return emitDecision(
355
+ 'deny',
356
+ 'aimhooman strict profile forbids bypassing repository policy hooks (--no-verify/-n or core.hooksPath override).'
357
+ );
358
+ }
359
+
360
+ // A preceding command that may have changed the repository or its hooks is
361
+ // also a potential pre-commit bypass. Strict rejects it above; clean and
362
+ // compliance still need the staged-content backstop so a hook mutation
363
+ // cannot expose a secret that was already in the index.
364
+ const prefixBypass = parsed.commands.some((candidate) => (
365
+ (candidate.verb === 'commit' || candidate.verb === 'unknown')
366
+ && candidate.prefixRisk
367
+ )) || (parsed.uncertainShell && parsed.commands.length === 0 && parsed.prefixRisk);
368
+ const aliasBypass = parsed.commands.some((candidate) => (
369
+ (candidate.verb === 'commit' || candidate.verb === 'unknown')
370
+ && candidate.inlineAliasRisk
371
+ ));
372
+ const hiddenBypass = noVerify || bypassHooks || parsed.uncertainShell
373
+ || prefixBypass || aliasBypass;
374
+ const bypassContext = aliasBypass
375
+ ? 'an inline Git alias cannot be proved to preserve the pre-commit guard'
376
+ : prefixBypass
377
+ ? 'an earlier command may have changed the repository or its pre-commit guard'
378
+ : '--no-verify or shell indirection bypasses the pre-commit guard';
379
+ const unmodelledPrefixReason =
380
+ 'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
381
+ const blocks = [];
382
+ const potentialCommit = commit || parsed.uncertainShell;
383
+ if (potentialCommit && repo) {
384
+ try {
385
+ const entries = stagedEntries(repo);
386
+ for (const entry of entries) {
387
+ blocks.push(...eng.checkPaths([entry.path], {
388
+ objectId: entry.oid,
389
+ mode: entry.mode,
390
+ transition: 'staged',
391
+ })
392
+ .filter((finding) => finding.decision === 'block'));
393
+ }
394
+ // A direct --no-verify/core.hooksPath bypass disables pre-commit,
395
+ // so clean/compliance must inspect the immutable staged blobs here.
396
+ // Normal commits leave this work to pre-commit to keep the agent
397
+ // guard fast and avoid reading the same objects twice.
398
+ if (profile !== 'strict' && hiddenBypass) {
399
+ const scan = scanEntries(repo, eng, entries);
400
+ blocks.push(...scan.findings.filter((finding) => finding.decision === 'block'));
401
+ if (!scan.complete) {
402
+ return emitDecision(
403
+ 'deny',
404
+ `aimhooman cannot fully scan staged content while ${bypassContext}; run the commit separately without the bypass.`,
405
+ );
406
+ }
407
+ }
408
+ } catch (e) {
409
+ if (profile === 'strict' || hiddenBypass) {
410
+ return emitDecision('deny', `aimhooman cannot verify staged content: ${e.message}`);
411
+ }
412
+ }
413
+ }
414
+ if (addPaths.length) {
415
+ blocks.push(...eng.checkPaths(addPaths).filter((f) => f.decision === 'block'));
416
+ }
417
+ // Safety net for clean/compliance: --no-verify / core.hooksPath bypass
418
+ // disables the real pre-commit guard, leaving this hook the only check.
419
+ // When the commit also stages files at commit time (-a/--all/-u/--patch, or
420
+ // a preceding `git add`), those files are neither in stagedPaths(repo) now
421
+ // nor in addPaths, so they were never scanned. Rather than let an unscanned
422
+ // secret through, deny. (Strict already denied the bypass above.) The rare
423
+ // false-positive on an explicit `git add <path> && git commit --no-verify`
424
+ // is safe-side; drop --no-verify to proceed.
425
+ //
426
+ // Shell indirection (eval, sudo, wrappers, printf|sh, interpreters) can hide
427
+ // a --no-verify inside the inner command, where parsed.noVerify cannot see
428
+ // it (the parser only inspects literal argv). Such an uncertain commit is
429
+ // therefore treated as a potential hook bypass for the clean/compliance
430
+ // secret backstop, so a secret cannot slip past the guard wrapped in eval.
431
+ const indexReplacement = parsed.commands.some((candidate) => (
432
+ candidate.verb === 'commit' && candidate.indexMutationRisk
433
+ ));
434
+ if (profile !== 'strict' && commit && hiddenBypass
435
+ && parsed.commands.some((c) => c.verb === 'commit' && c.futureIndex)) {
436
+ if (indexReplacement) {
437
+ return emitDecision(
438
+ 'deny',
439
+ 'aimhooman cannot verify a commit after an earlier command replaces the Git index; run the index-changing command separately so the final staged snapshot can be scanned.'
440
+ );
441
+ }
442
+ return emitDecision(
443
+ 'deny',
444
+ 'aimhooman cannot verify a commit that stages files at commit time ' +
445
+ '(-a/--all/-u/--patch or a preceding git add) while --no-verify or shell ' +
446
+ 'indirection bypasses the pre-commit guard; run the commit directly without ' +
447
+ '--no-verify, or stage the files first. AI works, hoomans ship.',
448
+ );
449
+ }
450
+ if (blocks.length === 0) {
451
+ if (profile !== 'strict' && potentialCommit && prefixBypass) {
452
+ return emitDecision(
453
+ 'deny',
454
+ unmodelledPrefixReason,
455
+ );
456
+ }
457
+ if (diagnosticWarning) {
458
+ return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
459
+ }
460
+ return 0;
461
+ }
462
+
463
+ const reason = denyReason(blocks);
464
+ if (profile === 'strict') {
465
+ return emitDecision('deny', reason);
466
+ }
467
+ // clean / compliance: advisory only (the git pre-commit unstage is the real
468
+ // enforcement) — except when --no-verify/core.hooksPath bypasses that hook.
469
+ // A bypassed guard is the one case clean cannot delegate to git, so a blocked
470
+ // secret is denied here, matching the documented "secret stops the commit when
471
+ // automatic unstaging fails"; hygiene findings stay advisory.
472
+ const bypassed = Boolean(potentialCommit && hiddenBypass);
473
+ if (bypassed && blocks.some((f) => f.category === 'secret')) {
474
+ return emitDecision(
475
+ 'deny',
476
+ `aimhooman blocked this: ${bypassContext}, so ${reasonParts(blocks).join('; ')} would be committed. Run the commit separately without the bypass, or unstage it. AI works, hoomans ship.`,
477
+ );
478
+ }
479
+ if (profile !== 'strict' && potentialCommit && prefixBypass) {
480
+ return emitDecision(
481
+ 'deny',
482
+ unmodelledPrefixReason,
483
+ );
484
+ }
485
+ const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
486
+ const advisory = advisoryReason(blocks, guarded, bypassed ? bypassContext : '');
487
+ return emitDecision(
488
+ 'allow',
489
+ diagnosticWarning ? `${advisory} Warning: ${diagnosticWarning}.` : advisory
490
+ );
491
+ }
492
+
493
+ function toolName(i) {
494
+ return i.tool_name || i.toolName || '';
495
+ }
496
+
497
+ const EXECUTOR_SCHEMAS = new Map([
498
+ ['bash', ['command']],
499
+ ['cmd', ['command', 'cmd']],
500
+ ['exec', ['command', 'cmd']],
501
+ ['exec_command', ['cmd', 'command']],
502
+ ['execute', ['command', 'cmd']],
503
+ ['execute_command', ['command', 'cmd']],
504
+ ['fish', ['command']],
505
+ ['powershell', ['command']],
506
+ ['pwsh', ['command']],
507
+ ['run_command', ['command', 'cmd']],
508
+ ['run_shell_command', ['command']],
509
+ ['sh', ['command']],
510
+ ['shell', ['command']],
511
+ ['shell_command', ['command']],
512
+ ['terminal', ['command', 'cmd']],
513
+ ['terminal_exec', ['command', 'cmd']],
514
+ ['zsh', ['command']],
515
+ ]);
516
+ const NON_POSIX_TARGET_EXECUTORS = new Set(['cmd', 'fish', 'powershell', 'pwsh']);
517
+
518
+ // Host manifests intentionally receive every PreToolUse event. Only known
519
+ // command executors are interpreted here; unrelated tools are left alone.
520
+ function classifyExecutor(value) {
521
+ const original = String(value || '').trim().toLowerCase();
522
+ if (!original) return null;
523
+ const candidates = [original];
524
+ if (original.includes('__')) candidates.push(original.split('__').at(-1));
525
+ if (/[./:]/.test(original)) candidates.push(original.split(/[./:]/).at(-1));
526
+ for (const name of candidates) {
527
+ const fields = EXECUTOR_SCHEMAS.get(name);
528
+ if (fields) return { name, fields };
529
+ }
530
+ // A tool name containing an executor-like segment (run, git, exec, ...) is
531
+ // treated as a possible executor. This intentionally over-classifies: a
532
+ // non-executing tool with such a name and no command/cmd field reaches
533
+ // unknownExecutorShape, which under strict denies (safe side) rather than
534
+ // silently allowing a tool that might shell out. Narrowing this heuristic
535
+ // risks letting a real executor through, so the safe-side false deny is kept.
536
+ const segments = original.split(/[^a-z0-9]+/).filter(Boolean);
537
+ if (segments.some((segment) => [
538
+ 'bash', 'cmd', 'command', 'exec', 'execute', 'fish', 'git', 'powershell', 'pwsh',
539
+ 'run', 'sh', 'shell', 'terminal', 'zsh',
540
+ ].includes(segment))) {
541
+ return { name: original, fields: ['command', 'cmd'] };
542
+ }
543
+ return null;
544
+ }
545
+
546
+ function executorTargetSyntaxUncertain(executorName, value, parsed) {
547
+ if (!nonPosixExecutor(executorName)) return false;
548
+ const directoryCommand = shellUnits(String(value || '')).some((unit) => {
549
+ let tokens = unwrapShellControl(shellWords(unit.text.trim())).tokens;
550
+ while (['builtin', 'command'].includes(shellExecutable(tokens[0]))) tokens = tokens.slice(1);
551
+ return ['cd', 'popd', 'pushd', 'set-location', 'pop-location', 'push-location']
552
+ .includes(shellExecutable(tokens[0]));
553
+ });
554
+ return directoryCommand
555
+ || parsed.prefixRisk
556
+ || parsed.commands.some((candidate) => candidate.explicitTargetOption);
557
+ }
558
+
559
+ function nonPosixExecutor(executorName) {
560
+ return [String(executorName || ''), ...String(executorName || '').split(/[^a-z0-9]+/)]
561
+ .some((segment) => NON_POSIX_TARGET_EXECUTORS.has(segment));
562
+ }
563
+
564
+ function command(i, executor) {
565
+ let args = i.tool_input ?? i.toolArgs;
566
+ if (typeof args === 'string') {
567
+ try {
568
+ args = JSON.parse(args);
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
573
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
574
+ for (const field of executor.fields) {
575
+ if (typeof args[field] === 'string') return args[field];
576
+ }
577
+ return null;
578
+ }
579
+
580
+ function unknownExecutorShape(input, executorName) {
581
+ let repo;
582
+ try {
583
+ repo = openRepo();
584
+ } catch {
585
+ return 0;
586
+ }
587
+ try {
588
+ if (enforcementPolicy(repo).profile !== 'strict') return 0;
589
+ } catch (error) {
590
+ return emitDecision('deny', `aimhooman cannot load project policy: ${error.message}`);
591
+ }
592
+ return emitDecision(
593
+ 'deny',
594
+ `aimhooman cannot inspect the ${executorName} command payload in this strict repository; retry with a direct shell command.`
595
+ );
596
+ }
597
+
598
+ // parseGit inspects a shell command line for staged-path, commit, policy-transition,
599
+ // and branch/reference operations that need an agent-side guard decision.
600
+ export function parseGit(cmd, initialCwd = process.cwd()) {
601
+ let commit = false;
602
+ let noVerify = false;
603
+ let bypassHooks = false;
604
+ let uncertainShell = false;
605
+ let sawAdd = false;
606
+ const addPaths = [];
607
+ const commands = [];
608
+ if (!cmd) {
609
+ return {
610
+ commit,
611
+ noVerify,
612
+ bypassHooks,
613
+ uncertainShell,
614
+ classification: 'none',
615
+ environmentRisk: [],
616
+ prefixRisk: false,
617
+ addPaths,
618
+ commands,
619
+ };
620
+ }
621
+ uncertainShell = hasUncertainShellSyntax(cmd);
622
+ let shellCwd = initialCwd;
623
+ const directoryStack = [];
624
+ let subshellDepth = 0;
625
+ let previousOperator = null;
626
+ let shellTargetUncertain = false;
627
+ let shellPathDialectUncertain = false;
628
+ const definesShellFunction = /(?:^|[;&\n])\s*(?:function\s+)?[A-Za-z_][A-Za-z0-9_]*\s*(?:\(\s*\))?\s*\{/.test(cmd);
629
+ const persistentEnvironmentRisk = new Set();
630
+ let prefixRisk = false;
631
+ let prefixIndexMutationRisk = false;
632
+ let policyTransitionRisk = false;
633
+ for (const unit of shellUnits(cmd)) {
634
+ const precedingOperator = previousOperator;
635
+ previousOperator = unit.operator;
636
+ const subshellState = shellSubshellState(unit.text);
637
+ const unitInSubshell = subshellDepth > 0 || subshellState.opensSubshell;
638
+ const directoryExecutionUncertain = unitInSubshell
639
+ || uncertainShell
640
+ || precedingOperator === '&&'
641
+ || precedingOperator === '||';
642
+ subshellDepth = Math.max(0, subshellDepth + subshellState.delta);
643
+ const control = unwrapShellControl(shellWords(unit.text.trim()));
644
+ const words = control.tokens;
645
+ uncertainShell ||= control.uncertain;
646
+ if (updatePersistentEnvironment(words, persistentEnvironmentRisk)) continue;
647
+ const unwrapped = unwrapCommand(words, shellCwd);
648
+ const toks = unwrapped.tokens;
649
+ uncertainShell ||= unwrapped.uncertain;
650
+ let commandPathDialectUncertain = shellPathDialectUncertain
651
+ || unwrapped.pathDialectUncertain;
652
+ let commandTargetUncertain = shellTargetUncertain || unwrapped.targetUncertain;
653
+ let commandExplicitTargetOption = unwrapped.explicitTargetOption;
654
+ const nested = nestedShellPayload(toks);
655
+ if (nested !== null) {
656
+ const nestedStartupTargetUncertain = nestedShellStartupTargetUncertain(toks);
657
+ const outerEnvironmentRisk = unique([
658
+ ...persistentEnvironmentRisk,
659
+ ...assignmentRiskNames(unwrapped.assignments),
660
+ ]);
661
+ const outerTargetEnvironmentRisk = hasCrossTargetEnvironmentRisk(
662
+ outerEnvironmentRisk,
663
+ 'git',
664
+ );
665
+ const parsed = parseGit(nested, unwrapped.cwd);
666
+ commit ||= parsed.commit;
667
+ noVerify ||= parsed.noVerify;
668
+ bypassHooks ||= parsed.bypassHooks || outerEnvironmentRisk.length > 0;
669
+ uncertainShell = true;
670
+ addPaths.push(...parsed.addPaths);
671
+ commands.push(...parsed.commands.map((candidate) => ({
672
+ ...candidate,
673
+ bypassHooks: candidate.bypassHooks || outerEnvironmentRisk.length > 0,
674
+ environmentRisk: unique([
675
+ ...(candidate.environmentRisk || []),
676
+ ...outerEnvironmentRisk,
677
+ ]),
678
+ targetEnvironmentRisk: candidate.targetEnvironmentRisk
679
+ || outerTargetEnvironmentRisk,
680
+ explicitTargetOption: commandExplicitTargetOption
681
+ || candidate.explicitTargetOption,
682
+ targetUncertain: commandTargetUncertain
683
+ || nestedStartupTargetUncertain
684
+ || outerTargetEnvironmentRisk
685
+ || candidate.targetUncertain,
686
+ pathDialectUncertain: commandPathDialectUncertain
687
+ || candidate.pathDialectUncertain,
688
+ })));
689
+ sawAdd ||= parsed.addPaths.length > 0;
690
+ if (!parsed.commit) {
691
+ const positional = nestedShellArgumentCommitInvocation(toks, nested, unwrapped.cwd);
692
+ if (positional) {
693
+ commit = true;
694
+ commands.push({
695
+ verb: 'commit',
696
+ cwd: positional.cwd,
697
+ noVerify: false,
698
+ bypassHooks: outerEnvironmentRisk.length > 0,
699
+ futureIndex: true,
700
+ classification: 'uncertain',
701
+ environmentRisk: outerEnvironmentRisk,
702
+ targetEnvironmentRisk: outerTargetEnvironmentRisk,
703
+ prefixRisk,
704
+ policyTransitionRisk,
705
+ explicitTargetOption: commandExplicitTargetOption,
706
+ targetUncertain: commandTargetUncertain
707
+ || nestedStartupTargetUncertain
708
+ || outerTargetEnvironmentRisk
709
+ || positional.targetUncertain,
710
+ pathDialectUncertain: commandPathDialectUncertain
711
+ || positional.pathDialectUncertain,
712
+ });
713
+ }
714
+ }
715
+ continue;
716
+ }
717
+ const directoryTokens = directoryCommandTokens(toks);
718
+ if (String(directoryTokens[0]).replace(/^[({]+/, '') === 'cd') {
719
+ const args = directoryTokens.slice(1);
720
+ const hasEndOfOptions = args[0] === '--';
721
+ const positionals = hasEndOfOptions ? args.slice(1) : args;
722
+ const path = positionals[0];
723
+ const directoryEnvironmentRisk = assignmentRiskNames(unwrapped.assignments)
724
+ .some((name) => name === 'CDPATH' || name === 'HOME');
725
+ if (unitInSubshell || unwrapped.targetUncertain) {
726
+ shellTargetUncertain = true;
727
+ shellPathDialectUncertain ||= shellPathIsAmbiguous(path);
728
+ } else if (positionals.length !== 1
729
+ || (!hasEndOfOptions && path?.startsWith('-'))
730
+ || path === '-'
731
+ || shellPathHasExpansion(path)) {
732
+ shellTargetUncertain = true;
733
+ } else {
734
+ shellCwd = resolveShellPath(shellCwd, path);
735
+ shellTargetUncertain ||= directoryExecutionUncertain
736
+ || directoryEnvironmentRisk
737
+ || cdpathMayRedirect(path)
738
+ || (unit.operator !== '&&' && unit.operator !== null);
739
+ shellPathDialectUncertain ||= shellPathIsAmbiguous(path);
740
+ }
741
+ continue;
742
+ }
743
+ if ((unitInSubshell || unwrapped.targetUncertain)
744
+ && ['pushd', 'popd'].includes(shellExecutable(directoryTokens[0]))) {
745
+ shellTargetUncertain = true;
746
+ continue;
747
+ }
748
+ const stackChange = updateDirectoryStack(
749
+ toks,
750
+ shellCwd,
751
+ directoryStack,
752
+ unit.operator,
753
+ );
754
+ if (stackChange) {
755
+ shellCwd = stackChange.cwd;
756
+ const directoryEnvironmentRisk = assignmentRiskNames(unwrapped.assignments)
757
+ .some((name) => name === 'CDPATH' || name === 'HOME');
758
+ shellTargetUncertain ||= directoryExecutionUncertain
759
+ || stackChange.uncertain
760
+ || directoryEnvironmentRisk;
761
+ shellPathDialectUncertain ||= stackChange.pathDialectUncertain;
762
+ continue;
763
+ }
764
+ if (SHELL_CONTROL_DECLARATIONS.has(toks[0])) continue;
765
+ if (toks.length < 2) {
766
+ if (toks.length && !READ_ONLY_SHELL_COMMANDS.has(basename(toks[0]))) {
767
+ prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
768
+ prefixRisk = true;
769
+ }
770
+ continue;
771
+ }
772
+ const g0 = toks[0];
773
+ const environmentRisk = unique([
774
+ ...persistentEnvironmentRisk,
775
+ ...assignmentRiskNames(unwrapped.assignments),
776
+ ]);
777
+ const targetEnvironmentRisk = hasCrossTargetEnvironmentRisk(environmentRisk, g0);
778
+ if (!isGitExecutable(g0)) {
779
+ if (CURRENT_SHELL_MUTATORS.has(shellExecutable(g0))) uncertainShell = true;
780
+ const indirect = indirectCommitInvocation(toks, unwrapped.cwd)
781
+ || interpretedCommitInvocation(toks, unwrapped.cwd)
782
+ || (unwrapped.uncertain && possibleGitCommit(words)
783
+ ? indirectCommitDetails(words, unwrapped.cwd, true)
784
+ : null);
785
+ if (indirect) {
786
+ const indirectTargetEnvironmentRisk = hasCrossTargetEnvironmentRisk(
787
+ environmentRisk,
788
+ 'git',
789
+ );
790
+ commit = true;
791
+ uncertainShell = true;
792
+ commands.push({
793
+ verb: 'commit',
794
+ cwd: indirect.cwd,
795
+ noVerify: false,
796
+ bypassHooks: environmentRisk.length > 0,
797
+ futureIndex: true,
798
+ classification: 'uncertain',
799
+ environmentRisk,
800
+ targetEnvironmentRisk: targetEnvironmentRisk
801
+ || indirectTargetEnvironmentRisk,
802
+ prefixRisk,
803
+ policyTransitionRisk,
804
+ explicitTargetOption: commandExplicitTargetOption
805
+ || indirect.explicitTargetOption,
806
+ targetUncertain: commandTargetUncertain
807
+ || indirect.targetUncertain,
808
+ pathDialectUncertain: commandPathDialectUncertain
809
+ || indirect.pathDialectUncertain,
810
+ });
811
+ }
812
+ if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))) {
813
+ prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
814
+ prefixRisk = true;
815
+ if (CURRENT_SHELL_MUTATORS.has(shellExecutable(g0)) || definesShellFunction) {
816
+ shellTargetUncertain = true;
817
+ }
818
+ }
819
+ continue;
820
+ }
821
+ let i = 1;
822
+ let cwd = unwrapped.cwd;
823
+ let commandBypass = environmentRisk.length > 0;
824
+ let aliasResolutionRisk = !canResolveGitAlias(g0);
825
+ let inlineAliasRisk = false;
826
+ while (i < toks.length && toks[i].startsWith('-')) {
827
+ const option = toks[i];
828
+ if (option === '-C' && toks[i + 1]) {
829
+ commandExplicitTargetOption = true;
830
+ commandTargetUncertain ||= shellPathHasExpansion(toks[i + 1]);
831
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(toks[i + 1]);
832
+ cwd = resolveShellPath(cwd, toks[i + 1]);
833
+ i += 2;
834
+ } else if (option.startsWith('-C') && option.length > 2) {
835
+ commandExplicitTargetOption = true;
836
+ const target = option.slice(2);
837
+ commandTargetUncertain ||= shellPathHasExpansion(target);
838
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(target);
839
+ cwd = resolveShellPath(cwd, target);
840
+ i += 1;
841
+ } else if (option === '-c' && toks[i + 1]) {
842
+ if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
843
+ if (aliasAffectingConfig(toks[i + 1])) {
844
+ aliasResolutionRisk = true;
845
+ inlineAliasRisk = true;
846
+ }
847
+ i += 2;
848
+ } else if (option.startsWith('-c') && option.length > 2) {
849
+ if (hookAffectingConfig(option.slice(2))) commandBypass = true;
850
+ if (aliasAffectingConfig(option.slice(2))) {
851
+ aliasResolutionRisk = true;
852
+ inlineAliasRisk = true;
853
+ }
854
+ i += 1;
855
+ } else if (option === '--config-env' && toks[i + 1]) {
856
+ if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
857
+ if (aliasAffectingConfig(toks[i + 1])) {
858
+ aliasResolutionRisk = true;
859
+ inlineAliasRisk = true;
860
+ }
861
+ i += 2;
862
+ } else if (option.startsWith('--config-env=')) {
863
+ if (hookAffectingConfig(option.slice('--config-env='.length))) commandBypass = true;
864
+ if (aliasAffectingConfig(option.slice('--config-env='.length))) {
865
+ aliasResolutionRisk = true;
866
+ inlineAliasRisk = true;
867
+ }
868
+ i += 1;
869
+ } else if (option === '--git-dir' && toks[i + 1]) {
870
+ commandExplicitTargetOption = true;
871
+ commandTargetUncertain ||= shellPathHasExpansion(toks[i + 1]);
872
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(toks[i + 1]);
873
+ const gitDir = resolveShellPath(cwd, toks[i + 1]);
874
+ cwd = basename(gitDir) === '.git' ? dirname(gitDir) : gitDir;
875
+ i += 2;
876
+ } else if (option.startsWith('--git-dir=')) {
877
+ commandExplicitTargetOption = true;
878
+ const target = option.slice('--git-dir='.length);
879
+ commandTargetUncertain ||= shellPathHasExpansion(target);
880
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(target);
881
+ const gitDir = resolveShellPath(cwd, target);
882
+ cwd = basename(gitDir) === '.git' ? dirname(gitDir) : gitDir;
883
+ i += 1;
884
+ } else if (option === '--work-tree' && toks[i + 1]) {
885
+ commandExplicitTargetOption = true;
886
+ // --work-tree does not select Git's index, hooks, or policy
887
+ // repository. This single-cwd model cannot safely represent a
888
+ // split git-dir/worktree target, so guarded operations fail closed.
889
+ commandTargetUncertain = true;
890
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(toks[i + 1]);
891
+ i += 2;
892
+ } else if (option.startsWith('--work-tree=')) {
893
+ commandExplicitTargetOption = true;
894
+ const target = option.slice('--work-tree='.length);
895
+ commandTargetUncertain = true;
896
+ commandPathDialectUncertain ||= shellPathIsAmbiguous(target);
897
+ i += 1;
898
+ } else {
899
+ i += option === '--namespace' ? 2 : 1;
900
+ }
901
+ }
902
+ if (i >= toks.length) continue;
903
+ const verb = toks[i];
904
+ const verbArgs = toks.slice(i + 1);
905
+ // A custom receive-pack can turn a seemingly remote push into a local
906
+ // receive-pack invocation whose hook path is outside the command we
907
+ // inspected. Treat that explicit indirection like a hook override.
908
+ commandBypass ||= gitCommandMayBypassRefGuard(verb, verbArgs);
909
+ // These commands can change the index before a later commit in the same
910
+ // shell command. The current staged snapshot is therefore not the index
911
+ // the commit will use, even when no literal `git add` appeared.
912
+ if (gitIndexMutationRisk(verb, verbArgs)) sawAdd = true;
913
+ if (uncertainShell && /\$\(|`/.test(verb)) {
914
+ commit = true;
915
+ bypassHooks ||= commandBypass;
916
+ commands.push({
917
+ verb: 'commit',
918
+ cwd,
919
+ noVerify: false,
920
+ bypassHooks: commandBypass,
921
+ futureIndex: true,
922
+ classification: 'uncertain',
923
+ environmentRisk,
924
+ targetEnvironmentRisk,
925
+ prefixRisk,
926
+ policyTransitionRisk,
927
+ explicitTargetOption: commandExplicitTargetOption,
928
+ targetUncertain: commandTargetUncertain,
929
+ pathDialectUncertain: commandPathDialectUncertain,
930
+ });
931
+ policyTransitionRisk = true;
932
+ continue;
933
+ }
934
+ if (verb === 'commit') {
935
+ commit = true;
936
+ const commitOptions = inspectCommitOptions(toks.slice(i + 1));
937
+ const commandNoVerify = commitOptions.noVerify;
938
+ noVerify ||= commandNoVerify;
939
+ bypassHooks ||= commandBypass;
940
+ const futureIndex = sawAdd || commitOptions.futureIndex || prefixIndexMutationRisk;
941
+ commands.push({
942
+ verb,
943
+ cwd,
944
+ noVerify: commandNoVerify,
945
+ bypassHooks: commandBypass,
946
+ futureIndex,
947
+ editorRisk: commitOptions.editorRisk,
948
+ classification: uncertainShell
949
+ ? 'uncertain'
950
+ : commandNoVerify || commandBypass
951
+ ? 'bypass'
952
+ : futureIndex
953
+ ? 'future-index'
954
+ : 'direct',
955
+ environmentRisk,
956
+ targetEnvironmentRisk,
957
+ prefixRisk,
958
+ indexMutationRisk: prefixIndexMutationRisk,
959
+ policyTransitionRisk,
960
+ explicitTargetOption: commandExplicitTargetOption,
961
+ targetUncertain: commandTargetUncertain,
962
+ pathDialectUncertain: commandPathDialectUncertain,
963
+ });
964
+ policyTransitionRisk = true;
965
+ }
966
+ else if (verb === 'add') {
967
+ const paths = toks.slice(i + 1).filter((t) => !t.startsWith('-'));
968
+ addPaths.push(...paths);
969
+ commands.push({
970
+ verb,
971
+ cwd,
972
+ addPaths: paths,
973
+ environmentRisk,
974
+ targetEnvironmentRisk,
975
+ explicitTargetOption: commandExplicitTargetOption,
976
+ targetUncertain: commandTargetUncertain,
977
+ pathDialectUncertain: commandPathDialectUncertain,
978
+ });
979
+ sawAdd = true;
980
+ } else if (verb === 'config') {
981
+ prefixRisk ||= gitConfigMayMutate(toks.slice(i + 1));
982
+ } else if (verb === 'init') {
983
+ prefixRisk = true;
984
+ } else if (!SAFE_NON_COMMIT_GIT.has(verb)) {
985
+ commands.push({
986
+ verb: 'unknown',
987
+ subcommand: verb,
988
+ args: verbArgs,
989
+ cwd,
990
+ bypassHooks: commandBypass,
991
+ environmentRisk,
992
+ targetEnvironmentRisk,
993
+ prefixRisk,
994
+ policyTransitionRisk,
995
+ aliasResolutionRisk,
996
+ inlineAliasRisk,
997
+ explicitTargetOption: commandExplicitTargetOption,
998
+ targetUncertain: commandTargetUncertain,
999
+ pathDialectUncertain: commandPathDialectUncertain,
1000
+ classification: 'unknown',
1001
+ });
1002
+ policyTransitionRisk ||= GIT_POLICY_TRANSITION_COMMANDS.has(verb);
1003
+ } else if (GIT_POLICY_TRANSITION_COMMANDS.has(verb)
1004
+ || GIT_REF_MUTATION_COMMANDS.has(verb)) {
1005
+ commands.push({
1006
+ verb,
1007
+ args: verbArgs,
1008
+ cwd,
1009
+ bypassHooks: commandBypass,
1010
+ environmentRisk,
1011
+ targetEnvironmentRisk,
1012
+ prefixRisk,
1013
+ policyTransitionRisk,
1014
+ explicitTargetOption: commandExplicitTargetOption,
1015
+ targetUncertain: commandTargetUncertain,
1016
+ pathDialectUncertain: commandPathDialectUncertain,
1017
+ classification: 'mutation',
1018
+ });
1019
+ policyTransitionRisk = true;
1020
+ }
1021
+ }
1022
+ const indirectCommits = [
1023
+ ...pipedShellCommitInvocations(cmd, initialCwd),
1024
+ ...dynamicLiteralCommitInvocations(cmd, initialCwd),
1025
+ ];
1026
+ for (const indirect of indirectCommits) {
1027
+ const indirectBypass = Boolean(
1028
+ indirect.bypassHooks || indirect.environmentRisk?.length,
1029
+ );
1030
+ commit = true;
1031
+ uncertainShell = true;
1032
+ bypassHooks ||= indirectBypass;
1033
+ commands.push({
1034
+ verb: 'commit',
1035
+ cwd: indirect.cwd,
1036
+ noVerify: false,
1037
+ bypassHooks: indirectBypass,
1038
+ futureIndex: true,
1039
+ classification: 'uncertain',
1040
+ environmentRisk: indirect.environmentRisk || [],
1041
+ targetEnvironmentRisk: indirect.targetEnvironmentRisk || false,
1042
+ prefixRisk,
1043
+ policyTransitionRisk,
1044
+ explicitTargetOption: indirect.explicitTargetOption || false,
1045
+ targetUncertain: indirect.targetUncertain,
1046
+ pathDialectUncertain: indirect.pathDialectUncertain,
1047
+ });
1048
+ }
1049
+ const classification = !commit
1050
+ ? 'none'
1051
+ : uncertainShell
1052
+ ? 'uncertain'
1053
+ : noVerify || bypassHooks
1054
+ ? 'bypass'
1055
+ : commands.some((candidate) => candidate.verb === 'commit' && candidate.futureIndex)
1056
+ ? 'future-index'
1057
+ : 'direct';
1058
+ return {
1059
+ commit,
1060
+ noVerify,
1061
+ bypassHooks,
1062
+ uncertainShell,
1063
+ classification,
1064
+ environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
1065
+ prefixRisk,
1066
+ addPaths,
1067
+ commands,
1068
+ };
1069
+ }
1070
+
1071
+ const READ_ONLY_SHELL_COMMANDS = new Set(['echo', 'printf', 'pwd', 'test', 'true', '[']);
1072
+
1073
+ const SHELL_CONTROL_PREFIXES = new Set([
1074
+ '!', 'if', 'then', 'elif', 'else', 'while', 'until', 'do',
1075
+ ]);
1076
+
1077
+ const SHELL_CONTROL_WORDS = new Set([
1078
+ ...SHELL_CONTROL_PREFIXES,
1079
+ 'case', 'esac', 'fi', 'for', 'function', 'done', 'select',
1080
+ ]);
1081
+
1082
+ const SHELL_CONTROL_DECLARATIONS = new Set([
1083
+ 'case', 'done', 'esac', 'fi', 'for', 'function', 'select',
1084
+ ]);
1085
+
1086
+ const CURRENT_SHELL_MUTATORS = new Set([
1087
+ '.', 'alias', 'autoload', 'enable', 'eval', 'hash', 'rehash', 'setopt',
1088
+ 'source', 'unalias', 'unfunction',
1089
+ ]);
1090
+
1091
+ // A compound shell segment such as `then git commit` is still tokenized as a
1092
+ // command whose first word is `then`. Strip only reserved prefixes that can be
1093
+ // followed by an executable command. The whole input remains uncertain so
1094
+ // strict denies it, while clean/compliance can inspect the surfaced commit's
1095
+ // staged content before a host allows execution.
1096
+ function unwrapShellControl(input) {
1097
+ const tokens = [...input];
1098
+ let uncertain = false;
1099
+ while (SHELL_CONTROL_PREFIXES.has(tokens[0])) {
1100
+ uncertain = true;
1101
+ tokens.shift();
1102
+ }
1103
+ return { tokens, uncertain };
1104
+ }
1105
+
1106
+ function updateDirectoryStack(tokens, cwd, stack, followingOperator) {
1107
+ const command = directoryCommandTokens(tokens);
1108
+ const executable = shellExecutable(command[0]);
1109
+ if (executable !== 'pushd' && executable !== 'popd') return null;
1110
+ const args = command.slice(1);
1111
+ const conditionalOnSuccess = followingOperator === '&&' || followingOperator === null;
1112
+ if (args.some((arg) => arg.startsWith('-') && arg !== '--')
1113
+ || args.some((arg) => /^[+-]\d+$/.test(arg))) {
1114
+ return { cwd, uncertain: true, pathDialectUncertain: false };
1115
+ }
1116
+ const positionals = args.filter((arg) => arg !== '--');
1117
+ if (executable === 'pushd') {
1118
+ if (positionals.length > 1) return { cwd, uncertain: true, pathDialectUncertain: false };
1119
+ if (positionals.length === 0) {
1120
+ if (!stack.length) return { cwd, uncertain: true, pathDialectUncertain: false };
1121
+ const previous = stack.pop();
1122
+ stack.push(cwd);
1123
+ return {
1124
+ cwd: previous,
1125
+ uncertain: !conditionalOnSuccess,
1126
+ pathDialectUncertain: false,
1127
+ };
1128
+ }
1129
+ const path = positionals[0];
1130
+ if (shellPathHasExpansion(path)) {
1131
+ return { cwd, uncertain: true, pathDialectUncertain: false };
1132
+ }
1133
+ stack.push(cwd);
1134
+ return {
1135
+ cwd: resolveShellPath(cwd, path),
1136
+ uncertain: !conditionalOnSuccess || cdpathMayRedirect(path),
1137
+ pathDialectUncertain: shellPathIsAmbiguous(path),
1138
+ };
1139
+ }
1140
+ if (positionals.length || !stack.length) {
1141
+ return { cwd, uncertain: true, pathDialectUncertain: false };
1142
+ }
1143
+ return { cwd: stack.pop(), uncertain: !conditionalOnSuccess, pathDialectUncertain: false };
1144
+ }
1145
+
1146
+ function directoryCommandTokens(tokens) {
1147
+ let command = tokens;
1148
+ let executable = shellExecutable(command[0]);
1149
+ while (executable === '-' || executable === 'nocorrect' || executable === 'noglob') {
1150
+ command = command.slice(1);
1151
+ executable = shellExecutable(command[0]);
1152
+ }
1153
+ if (executable === 'builtin') {
1154
+ command = command.slice(1);
1155
+ if (command[0] === '--') command = command.slice(1);
1156
+ }
1157
+ return command;
1158
+ }
1159
+
1160
+ // A literal prefix that addresses Git's index makes the staged snapshot read
1161
+ // by PreToolUse stale before Git starts. This catches direct copy/install/move,
1162
+ // redirection, and worktree index paths without treating every ordinary build
1163
+ // command before a commit as an index replacement.
1164
+ function commandMayReplaceIndex(tokens, source) {
1165
+ const text = `${tokens.join(' ')} ${source}`.replace(/\\/g, '/');
1166
+ return /(?:^|[\s"'=])(?:\.git\/index|[^\s"']+\/\.git\/index)(?:\.lock)?(?:$|[\s"'])/i.test(text)
1167
+ || /\bGIT_INDEX_FILE\b/.test(text)
1168
+ || /\bgit\s+rev-parse\b[^;&|\n]*\b--git-path(?:=|\s+)index\b/i.test(text);
1169
+ }
1170
+
1171
+ const GIT_POLICY_TRANSITION_COMMANDS = new Set([
1172
+ 'am', 'branch', 'checkout', 'cherry-pick', 'commit', 'merge', 'pull', 'read-tree', 'rebase',
1173
+ 'replace', 'reset', 'restore', 'revert', 'rm', 'switch', 'symbolic-ref', 'update-index',
1174
+ 'update-ref',
1175
+ ]);
1176
+
1177
+ // These verbs can create a commit or move a branch/reference. Their final
1178
+ // safety boundary is the managed reference-transaction hook, so PreToolUse
1179
+ // must not let config/environment/prefix indirection disable that boundary.
1180
+ const GIT_REF_MUTATION_COMMANDS = new Set([
1181
+ 'am', 'bisect', 'branch', 'checkout', 'cherry-pick', 'fetch', 'merge', 'pull', 'push',
1182
+ 'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch', 'symbolic-ref',
1183
+ 'update-ref', 'worktree',
1184
+ ]);
1185
+
1186
+ function gitCommandMayBypassRefGuard(verb, args = []) {
1187
+ if (verb !== 'push') return false;
1188
+ return args.some((arg) => arg === '--receive-pack'
1189
+ || arg === '--exec'
1190
+ || arg.startsWith('--receive-pack=')
1191
+ || arg.startsWith('--exec='));
1192
+ }
1193
+
1194
+ function configuredPushReceiver(repo) {
1195
+ try {
1196
+ return execFileSync(
1197
+ 'git',
1198
+ ['config', '--get-regexp', '^remote\\..*\\.receivepack$'],
1199
+ { cwd: repo.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
1200
+ ).trim().length > 0;
1201
+ } catch (error) {
1202
+ if (error?.status === 1) return false;
1203
+ throw error;
1204
+ }
1205
+ }
1206
+
1207
+ function isProtectedMutation(candidate) {
1208
+ return candidate?.verb === 'commit'
1209
+ || candidate?.verb === 'unknown'
1210
+ || GIT_REF_MUTATION_COMMANDS.has(candidate?.verb);
1211
+ }
1212
+
1213
+ function isGuardedCandidate(candidate) {
1214
+ return candidate?.verb === 'add' || isProtectedMutation(candidate);
1215
+ }
1216
+
1217
+ const GIT_INDEX_MUTATION_COMMANDS = new Set([
1218
+ 'add', 'checkout', 'mv', 'read-tree', 'reset', 'rm', 'switch', 'update-index',
1219
+ ]);
1220
+
1221
+ function gitIndexMutationRisk(verb, args = []) {
1222
+ if (GIT_INDEX_MUTATION_COMMANDS.has(verb)) return true;
1223
+ if (verb === 'apply') {
1224
+ return args.some((arg) => [
1225
+ '--3way', '-3', '--cached', '--index', '--intent-to-add', '-N',
1226
+ ].includes(arg));
1227
+ }
1228
+ if (verb === 'restore') {
1229
+ return args.some((arg) => arg === '--staged' || arg === '-S'
1230
+ || (/^-[^-]/.test(arg) && arg.slice(1).includes('S')));
1231
+ }
1232
+ if (verb === 'stash') {
1233
+ const subcommand = args.find((arg) => !arg.startsWith('-')) || 'push';
1234
+ return !['list', 'show'].includes(subcommand);
1235
+ }
1236
+ if (verb === 'submodule') {
1237
+ return args.find((arg) => !arg.startsWith('-')) === 'add';
1238
+ }
1239
+ return false;
1240
+ }
1241
+
1242
+ function gitConfigMayMutate(args) {
1243
+ if (args.some((arg) => [
1244
+ '--add', '--edit', '-e', '--remove-section', '--rename-section',
1245
+ '--replace-all', '--unset', '--unset-all',
1246
+ ].includes(arg))) return true;
1247
+ if (args.some((arg) => [
1248
+ '--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l',
1249
+ ].includes(arg))) return false;
1250
+ const positionals = args.filter((arg) => !arg.startsWith('-'));
1251
+ return positionals.length > 1;
1252
+ }
1253
+
1254
+ const SAFE_NON_COMMIT_GIT = new Set([
1255
+ 'add', 'am', 'annotate', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file',
1256
+ 'check-attr', 'check-ignore', 'check-mailmap', 'check-ref-format', 'checkout',
1257
+ 'cherry-pick', 'clean', 'clone', 'column', 'config', 'count-objects', 'credential', 'describe',
1258
+ 'diff', 'diff-files', 'diff-index', 'diff-tree', 'difftool', 'fetch', 'for-each-ref',
1259
+ 'fsck', 'gc', 'grep', 'hash-object', 'help', 'index-pack', 'init', 'interpret-trailers',
1260
+ 'log', 'ls-files', 'ls-remote', 'ls-tree', 'merge', 'merge-base', 'mktag', 'mktree', 'mv',
1261
+ 'name-rev', 'pack-objects', 'prune', 'pull', 'push', 'range-diff', 'read-tree', 'rebase',
1262
+ 'reflog', 'remote', 'repack', 'replace', 'reset', 'restore', 'rev-list', 'rev-parse', 'revert',
1263
+ 'rm', 'shortlog', 'show', 'show-branch', 'show-ref', 'sparse-checkout', 'stash', 'status',
1264
+ 'submodule', 'switch', 'symbolic-ref', 'tag', 'unpack-objects', 'update-index',
1265
+ 'update-ref', 'verify-commit', 'verify-pack', 'verify-tag', 'version', 'whatchanged',
1266
+ 'worktree', 'write-tree',
1267
+ ]);
1268
+
1269
+ function resolveGitAliases(parsed) {
1270
+ const commands = [];
1271
+ const aliasAddPaths = [];
1272
+ let aliasPolicyTransitionRisk = false;
1273
+ let aliasIndexMutationRisk = false;
1274
+ let aliasPrefixRisk = false;
1275
+ for (const candidate of parsed.commands) {
1276
+ let resolved = candidate;
1277
+ if (candidate.verb === 'unknown') {
1278
+ if (
1279
+ candidate.prefixRisk
1280
+ || candidate.policyTransitionRisk
1281
+ || candidate.aliasResolutionRisk
1282
+ || candidate.bypassHooks
1283
+ || candidate.environmentRisk?.length
1284
+ ) {
1285
+ resolved = candidate;
1286
+ } else {
1287
+ resolved = resolveAliasCandidate(candidate);
1288
+ }
1289
+ }
1290
+ if (!resolved) continue;
1291
+ const commitLike = resolved.verb === 'commit' || resolved.verb === 'unknown';
1292
+ const effective = commitLike ? {
1293
+ ...resolved,
1294
+ policyTransitionRisk: Boolean(
1295
+ resolved.policyTransitionRisk || aliasPolicyTransitionRisk
1296
+ ),
1297
+ futureIndex: Boolean(resolved.futureIndex || aliasIndexMutationRisk),
1298
+ prefixRisk: Boolean(resolved.prefixRisk || aliasPrefixRisk),
1299
+ } : resolved;
1300
+ commands.push(effective);
1301
+ if (effective.verb === 'add') aliasAddPaths.push(...effective.addPaths);
1302
+ aliasPolicyTransitionRisk ||= effective.verb === 'unknown'
1303
+ || GIT_POLICY_TRANSITION_COMMANDS.has(effective.verb)
1304
+ || GIT_REF_MUTATION_COMMANDS.has(effective.verb);
1305
+ aliasIndexMutationRisk ||= effective.verb === 'unknown'
1306
+ || effective.indexMutationRisk
1307
+ || gitIndexMutationRisk(effective.verb, effective.args);
1308
+ aliasPrefixRisk ||= effective.verb === 'unknown' || effective.prefixMutationRisk;
1309
+ }
1310
+ const commitCommands = commands.filter((candidate) => (
1311
+ candidate.verb === 'commit' || candidate.verb === 'unknown'
1312
+ ));
1313
+ const noVerify = commitCommands.some((candidate) => candidate.noVerify);
1314
+ const bypassHooks = commitCommands.some((candidate) => candidate.bypassHooks);
1315
+ const unknown = commitCommands.some((candidate) => candidate.verb === 'unknown');
1316
+ const futureIndex = commitCommands.some((candidate) => candidate.futureIndex);
1317
+ return {
1318
+ ...parsed,
1319
+ commit: commitCommands.length > 0,
1320
+ noVerify,
1321
+ bypassHooks,
1322
+ addPaths: [...parsed.addPaths, ...aliasAddPaths],
1323
+ commands,
1324
+ environmentRisk: unique(commitCommands.flatMap((candidate) => candidate.environmentRisk || [])),
1325
+ classification: parsed.uncertainShell
1326
+ ? 'uncertain'
1327
+ : unknown
1328
+ ? 'unknown'
1329
+ : noVerify || bypassHooks
1330
+ ? 'bypass'
1331
+ : futureIndex
1332
+ ? 'future-index'
1333
+ : commitCommands.length ? 'direct' : 'none',
1334
+ };
1335
+ }
1336
+
1337
+ function resolveAliasCandidate(candidate) {
1338
+ let subcommand = candidate.subcommand;
1339
+ let args = [...candidate.args];
1340
+ const seen = new Set();
1341
+ for (let depth = 0; depth < 8; depth++) {
1342
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(subcommand) || seen.has(subcommand)) {
1343
+ return { ...candidate, subcommand, aliasCycle: seen.has(subcommand) };
1344
+ }
1345
+ seen.add(subcommand);
1346
+ const alias = readGitAlias(candidate.cwd, subcommand);
1347
+ if (alias === null) return { ...candidate, subcommand };
1348
+ if (alias.trimStart().startsWith('!')) {
1349
+ const shellParsed = parseGit(alias.trimStart().slice(1), candidate.cwd);
1350
+ const shellCommits = shellParsed.commands.filter((command) => (
1351
+ command.verb === 'commit' || command.verb === 'unknown'
1352
+ ));
1353
+ return {
1354
+ ...candidate,
1355
+ subcommand,
1356
+ alias: candidate.subcommand,
1357
+ aliasExpansion: alias,
1358
+ aliasShell: true,
1359
+ noVerify: shellParsed.noVerify,
1360
+ bypassHooks: shellParsed.bypassHooks,
1361
+ futureIndex: shellCommits.some((command) => command.futureIndex),
1362
+ targetUncertain: candidate.targetUncertain
1363
+ || shellCommits.some((command) => command.targetUncertain),
1364
+ pathDialectUncertain: candidate.pathDialectUncertain
1365
+ || shellCommits.some((command) => command.pathDialectUncertain),
1366
+ prefixRisk: true,
1367
+ policyTransitionRisk: shellCommits.some((command) => (
1368
+ command.policyTransitionRisk
1369
+ )),
1370
+ classification: 'unknown',
1371
+ };
1372
+ }
1373
+ const words = shellWords(alias);
1374
+ if (!words.length || words[0].startsWith('-')) {
1375
+ return { ...candidate, subcommand };
1376
+ }
1377
+ const verb = words[0];
1378
+ args = [...words.slice(1), ...args];
1379
+ if (verb === 'commit') {
1380
+ const options = inspectCommitOptions(args);
1381
+ return {
1382
+ ...candidate,
1383
+ verb: 'commit',
1384
+ alias: candidate.subcommand,
1385
+ aliasExpansion: alias,
1386
+ noVerify: options.noVerify,
1387
+ bypassHooks: candidate.bypassHooks,
1388
+ futureIndex: options.futureIndex,
1389
+ editorRisk: options.editorRisk,
1390
+ classification: candidate.bypassHooks || options.noVerify
1391
+ ? 'bypass'
1392
+ : options.futureIndex ? 'future-index' : 'direct',
1393
+ };
1394
+ }
1395
+ if (verb === 'add') {
1396
+ const paths = args.filter((value) => !value.startsWith('-'));
1397
+ return { ...candidate, verb: 'add', addPaths: paths, classification: 'add' };
1398
+ }
1399
+ if (SAFE_NON_COMMIT_GIT.has(verb)) {
1400
+ const indexMutationRisk = gitIndexMutationRisk(verb, args);
1401
+ const prefixMutationRisk = verb === 'init'
1402
+ || (verb === 'config' && gitConfigMayMutate(args));
1403
+ const bypassHooks = candidate.bypassHooks
1404
+ || gitCommandMayBypassRefGuard(verb, args);
1405
+ if (GIT_POLICY_TRANSITION_COMMANDS.has(verb)
1406
+ || GIT_REF_MUTATION_COMMANDS.has(verb)
1407
+ || indexMutationRisk
1408
+ || prefixMutationRisk) {
1409
+ return {
1410
+ ...candidate,
1411
+ verb,
1412
+ args,
1413
+ alias: candidate.subcommand,
1414
+ aliasExpansion: alias,
1415
+ indexMutationRisk,
1416
+ prefixMutationRisk,
1417
+ bypassHooks,
1418
+ classification: 'non-commit',
1419
+ };
1420
+ }
1421
+ return null;
1422
+ }
1423
+ subcommand = verb;
1424
+ }
1425
+ return { ...candidate, subcommand, aliasCycle: true };
1426
+ }
1427
+
1428
+ function readGitAlias(cwd, name) {
1429
+ try {
1430
+ return execFileSync('git', ['config', '--get', `alias.${name}`], {
1431
+ cwd,
1432
+ encoding: 'utf8',
1433
+ stdio: ['ignore', 'pipe', 'ignore'],
1434
+ }).trim();
1435
+ } catch {
1436
+ return null;
1437
+ }
1438
+ }
1439
+
1440
+ function isGitExecutable(value) {
1441
+ const executable = String(value || '').replace(/\\/g, '/').split('/').at(-1).toLowerCase();
1442
+ return executable === 'git' || executable === 'git.exe';
1443
+ }
1444
+
1445
+ function canResolveGitAlias(value) {
1446
+ return /^(?:git|git\.exe)$/i.test(String(value || ''));
1447
+ }
1448
+
1449
+ function hookAffectingConfig(value) {
1450
+ const key = String(value).split('=', 1)[0];
1451
+ return /^(?:core\.(?:editor|hookspath)|sequence\.editor|include\.path|includeif\..+\.path|remote\..+\.receivepack)$/i.test(key);
1452
+ }
1453
+
1454
+ function aliasAffectingConfig(value) {
1455
+ const key = String(value).split('=', 1)[0];
1456
+ return /^alias\./i.test(key);
1457
+ }
1458
+
1459
+ const COMMIT_LONG_OPTIONS = new Map([
1460
+ ['ahead-behind', {}],
1461
+ ['all', { futureIndex: true }],
1462
+ ['allow-empty', {}],
1463
+ ['allow-empty-message', {}],
1464
+ ['amend', {}],
1465
+ ['author', { value: 'required' }],
1466
+ ['branch', {}],
1467
+ ['cleanup', { value: 'required' }],
1468
+ ['date', { value: 'required' }],
1469
+ ['dry-run', {}],
1470
+ ['edit', { editor: 'open' }],
1471
+ ['file', { value: 'required', messageSource: true }],
1472
+ ['fixup', { value: 'required', fixup: true }],
1473
+ ['gpg-sign', { value: 'optional' }],
1474
+ ['include', { futureIndex: true }],
1475
+ ['interactive', { futureIndex: true }],
1476
+ ['long', {}],
1477
+ ['message', { value: 'required', messageSource: true }],
1478
+ ['no-ahead-behind', {}],
1479
+ ['no-all', {}],
1480
+ ['no-amend', {}],
1481
+ ['no-author', { value: 'required' }],
1482
+ ['no-branch', {}],
1483
+ ['no-cleanup', { value: 'required' }],
1484
+ ['no-date', { value: 'required' }],
1485
+ ['no-dry-run', {}],
1486
+ ['no-edit', { editor: 'skip', messageSource: true }],
1487
+ ['no-file', { value: 'required' }],
1488
+ ['no-fixup', { value: 'required' }],
1489
+ ['no-gpg-sign', {}],
1490
+ ['no-interactive', {}],
1491
+ ['no-long', {}],
1492
+ ['no-message', { value: 'required' }],
1493
+ ['no-null', {}],
1494
+ ['no-only', {}],
1495
+ ['no-patch', {}],
1496
+ ['no-pathspec-file-nul', {}],
1497
+ ['no-pathspec-from-file', { value: 'required' }],
1498
+ ['no-porcelain', {}],
1499
+ ['no-post-rewrite', {}],
1500
+ ['no-quiet', {}],
1501
+ ['no-reedit-message', { value: 'required' }],
1502
+ ['no-reset-author', {}],
1503
+ ['no-reuse-message', { value: 'required' }],
1504
+ ['no-short', {}],
1505
+ ['no-signoff', {}],
1506
+ ['no-squash', { value: 'required' }],
1507
+ ['no-status', {}],
1508
+ ['no-template', { value: 'required' }],
1509
+ ['no-untracked-files', { value: 'optional' }],
1510
+ ['no-verbose', {}],
1511
+ ['no-verify', { noVerify: true }],
1512
+ ['null', {}],
1513
+ ['only', { futureIndex: true }],
1514
+ ['patch', { futureIndex: true }],
1515
+ ['pathspec-file-nul', {}],
1516
+ ['pathspec-from-file', { value: 'required', futureIndex: true }],
1517
+ ['porcelain', {}],
1518
+ ['post-rewrite', {}],
1519
+ ['quiet', {}],
1520
+ ['reedit-message', { value: 'required', editor: 'open', messageSource: true }],
1521
+ ['reset-author', {}],
1522
+ ['reuse-message', { value: 'required', messageSource: true }],
1523
+ ['short', {}],
1524
+ ['signoff', {}],
1525
+ ['squash', { value: 'required' }],
1526
+ ['status', {}],
1527
+ ['template', { value: 'required' }],
1528
+ ['trailer', { value: 'required' }],
1529
+ ['untracked-files', { value: 'optional' }],
1530
+ ['verbose', {}],
1531
+ ['verify', { noVerify: false }],
1532
+ ]);
1533
+
1534
+ const COMMIT_SHORT_OPTIONS = new Map([
1535
+ ['C', { value: 'required', messageSource: true }],
1536
+ ['F', { value: 'required', messageSource: true }],
1537
+ ['S', { value: 'optional' }],
1538
+ ['a', { futureIndex: true }],
1539
+ ['c', { value: 'required', editor: 'open', messageSource: true }],
1540
+ ['e', { editor: 'open' }],
1541
+ ['i', { futureIndex: true }],
1542
+ ['m', { value: 'required', messageSource: true }],
1543
+ ['n', { noVerify: true }],
1544
+ ['o', { futureIndex: true }],
1545
+ ['p', { futureIndex: true }],
1546
+ ['q', {}],
1547
+ ['s', {}],
1548
+ ['t', { value: 'required' }],
1549
+ ['u', { value: 'optional' }],
1550
+ ['v', {}],
1551
+ ['z', {}],
1552
+ ]);
1553
+
1554
+ function inspectCommitOptions(args) {
1555
+ let noVerify = false;
1556
+ let futureIndex = false;
1557
+ let messageSource = false;
1558
+ let editorOverride = null;
1559
+ for (let i = 0; i < args.length; i++) {
1560
+ const arg = args[i];
1561
+ if (arg === '--') {
1562
+ return {
1563
+ noVerify,
1564
+ futureIndex: futureIndex || i + 1 < args.length,
1565
+ editorRisk: editorOverride ?? !messageSource,
1566
+ };
1567
+ }
1568
+ if (arg.startsWith('--')) {
1569
+ const separator = arg.indexOf('=');
1570
+ const spelling = arg.slice(2, separator < 0 ? undefined : separator);
1571
+ const option = resolveLongCommitOption(spelling);
1572
+ if (!option) continue;
1573
+ const optionValue = separator < 0 ? args[i + 1] : arg.slice(separator + 1);
1574
+ if (Object.hasOwn(option, 'noVerify')) noVerify = option.noVerify;
1575
+ futureIndex ||= Boolean(option.futureIndex);
1576
+ messageSource ||= Boolean(option.messageSource);
1577
+ if (option.fixup && !/^(?:amend|reword):/.test(optionValue || '')) {
1578
+ messageSource = true;
1579
+ }
1580
+ if (option.editor) editorOverride = option.editor === 'open';
1581
+ if (option.value === 'required' && separator < 0) i += 1;
1582
+ continue;
1583
+ }
1584
+ if (arg.startsWith('-') && arg !== '-') {
1585
+ for (let offset = 1; offset < arg.length; offset++) {
1586
+ const option = COMMIT_SHORT_OPTIONS.get(arg[offset]);
1587
+ if (!option) continue;
1588
+ if (Object.hasOwn(option, 'noVerify')) noVerify = option.noVerify;
1589
+ futureIndex ||= Boolean(option.futureIndex);
1590
+ messageSource ||= Boolean(option.messageSource);
1591
+ if (option.editor) editorOverride = option.editor === 'open';
1592
+ if (option.value) {
1593
+ if (offset === arg.length - 1 && option.value === 'required') i += 1;
1594
+ break;
1595
+ }
1596
+ }
1597
+ continue;
1598
+ }
1599
+ futureIndex = true;
1600
+ }
1601
+ return { noVerify, futureIndex, editorRisk: editorOverride ?? !messageSource };
1602
+ }
1603
+
1604
+ function resolveLongCommitOption(spelling) {
1605
+ if (COMMIT_LONG_OPTIONS.has(spelling)) return COMMIT_LONG_OPTIONS.get(spelling);
1606
+ const matches = [...COMMIT_LONG_OPTIONS.keys()].filter((name) => name.startsWith(spelling));
1607
+ return matches.length === 1 ? COMMIT_LONG_OPTIONS.get(matches[0]) : null;
1608
+ }
1609
+
1610
+ function nestedShellPayload(tokens) {
1611
+ if (!tokens.length) return null;
1612
+ const executable = shellExecutable(tokens[0]);
1613
+ if (![
1614
+ 'bash', 'sh', 'zsh', 'dash', 'ksh', 'ash', 'mksh',
1615
+ 'fish', 'csh', 'tcsh', 'nu', 'elvish',
1616
+ ].includes(executable)) return null;
1617
+ const valueOptions = new Set(['-O', '-o', '--init-file', '--rcfile']);
1618
+ for (let i = 1; i < tokens.length; i++) {
1619
+ const option = tokens[i];
1620
+ if (option === '--') break;
1621
+ if (option === '-c' && typeof tokens[i + 1] === 'string') return tokens[i + 1];
1622
+ if (/^-[^-]*c/.test(option) && typeof tokens[i + 1] === 'string') return tokens[i + 1];
1623
+ if (option === '-C' && executable === 'fish') {
1624
+ i += 1;
1625
+ continue;
1626
+ }
1627
+ if (valueOptions.has(option)) {
1628
+ i += 1;
1629
+ continue;
1630
+ }
1631
+ if (!option.startsWith('-')) break;
1632
+ }
1633
+ return null;
1634
+ }
1635
+
1636
+ function nestedShellStartupTargetUncertain(tokens) {
1637
+ const executable = shellExecutable(tokens[0]);
1638
+ if (['csh', 'elvish', 'fish', 'nu', 'tcsh'].includes(executable)) return true;
1639
+ for (let index = 1; index < tokens.length; index++) {
1640
+ const option = tokens[index];
1641
+ if (option === '--' || option === '-c') break;
1642
+ if (/^-[^-]*c/.test(option)) {
1643
+ return /^-[^-]*[il]/.test(option);
1644
+ }
1645
+ if (['--init-file', '--rcfile'].includes(option)
1646
+ || option.startsWith('--init-file=')
1647
+ || option.startsWith('--rcfile=')) return true;
1648
+ if (option === '-C' && executable === 'fish') return true;
1649
+ if (/^-[^-]*[il]/.test(option)) return true;
1650
+ if (['-O', '-o'].includes(option)) index += 1;
1651
+ else if (!option.startsWith('-')) break;
1652
+ }
1653
+ return false;
1654
+ }
1655
+
1656
+ function nestedShellArgumentCommitInvocation(tokens, payload, initialCwd) {
1657
+ if (!/(?:^|[^A-Za-z0-9_])eval(?:[^A-Za-z0-9_]|$)|\$(?:[@*]|[1-9][0-9]*)/.test(payload)) {
1658
+ return null;
1659
+ }
1660
+ const payloadIndex = tokens.indexOf(payload);
1661
+ if (payloadIndex < 0 || payloadIndex === tokens.length - 1) return null;
1662
+ return literalCommitDetails(tokens.slice(payloadIndex + 1).join(' '), initialCwd);
1663
+ }
1664
+
1665
+ // Shell nesting, pipelines, background jobs, and command substitution change
1666
+ // cwd/execution scope in ways a non-executing parser cannot prove. A commit in
1667
+ // such a command is denied by the agent guard and can be retried as a direct
1668
+ // Git command, while ordinary `a && b`/`a || b` chains remain supported.
1669
+ function hasUncertainShellSyntax(command) {
1670
+ let quote = '';
1671
+ let escaped = false;
1672
+ for (let i = 0; i < command.length; i++) {
1673
+ const char = command[i];
1674
+ if (escaped) {
1675
+ escaped = false;
1676
+ continue;
1677
+ }
1678
+ if (char === '\\' && quote !== "'") {
1679
+ escaped = true;
1680
+ continue;
1681
+ }
1682
+ if (quote) {
1683
+ if (quote === '"' && (char === '`' || (char === '$' && command[i + 1] === '('))) {
1684
+ return true;
1685
+ }
1686
+ if (char === quote) quote = '';
1687
+ continue;
1688
+ }
1689
+ if (char === "'" || char === '"') {
1690
+ quote = char;
1691
+ continue;
1692
+ }
1693
+ if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}' || char === '<' || char === '>') return true;
1694
+ if (char === '|' && command[i + 1] !== '|') return true;
1695
+ if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
1696
+ }
1697
+ return shellUnits(command).some((unit) => {
1698
+ const words = shellWords(unit.text.trim());
1699
+ return SHELL_CONTROL_WORDS.has(words[0]);
1700
+ });
1701
+ }
1702
+
1703
+ function shellUnits(s) {
1704
+ const out = [];
1705
+ let segment = '';
1706
+ let quote = '';
1707
+ let escaped = false;
1708
+ const push = (operator) => {
1709
+ if (segment.trim()) out.push({ text: segment, operator });
1710
+ segment = '';
1711
+ };
1712
+ for (let i = 0; i < s.length; i++) {
1713
+ const c = s[i];
1714
+ if (escaped) {
1715
+ segment += c;
1716
+ escaped = false;
1717
+ } else if (c === '\\' && quote !== "'") {
1718
+ segment += c;
1719
+ escaped = true;
1720
+ } else if (quote) {
1721
+ segment += c;
1722
+ if (c === quote) quote = '';
1723
+ } else if (c === "'" || c === '"') {
1724
+ segment += c;
1725
+ quote = c;
1726
+ } else if (c === ';' || c === '\n' || c === '|' || c === '&') {
1727
+ let operator = c;
1728
+ if ((c === '|' && s[i + 1] === '|') || (c === '&' && s[i + 1] === '&')) {
1729
+ operator += s[++i];
1730
+ }
1731
+ push(operator);
1732
+ } else {
1733
+ segment += c;
1734
+ }
1735
+ }
1736
+ push(null);
1737
+ return out;
1738
+ }
1739
+
1740
+ function pipedShellCommitInvocations(source, initialCwd) {
1741
+ const units = shellUnits(source);
1742
+ const commits = [];
1743
+ let cwd = initialCwd;
1744
+ const directoryStack = [];
1745
+ let subshellDepth = 0;
1746
+ let previousOperator = null;
1747
+ const persistentEnvironmentRisk = new Set();
1748
+ let targetUncertain = false;
1749
+ let pathDialectUncertain = false;
1750
+ const uncertainSyntax = hasUncertainShellSyntax(source);
1751
+ const commands = units.map((unit) => {
1752
+ const precedingOperator = previousOperator;
1753
+ previousOperator = unit.operator;
1754
+ const subshellState = shellSubshellState(unit.text);
1755
+ const unitInSubshell = subshellDepth > 0 || subshellState.opensSubshell;
1756
+ const directoryExecutionUncertain = unitInSubshell
1757
+ || uncertainSyntax
1758
+ || precedingOperator === '&&'
1759
+ || precedingOperator === '||';
1760
+ subshellDepth = Math.max(0, subshellDepth + subshellState.delta);
1761
+ const words = shellWords(unit.text.trim());
1762
+ updatePersistentEnvironment(words, persistentEnvironmentRisk);
1763
+ const parsedCommand = unwrapCommand(words, cwd);
1764
+ const environmentRisk = unique([
1765
+ ...persistentEnvironmentRisk,
1766
+ ...assignmentRiskNames(parsedCommand.assignments),
1767
+ ]);
1768
+ const commandCwd = parsedCommand.cwd;
1769
+ const commandTargetUncertain = targetUncertain || parsedCommand.targetUncertain;
1770
+ const commandPathDialectUncertain = pathDialectUncertain
1771
+ || parsedCommand.pathDialectUncertain;
1772
+ const tokens = parsedCommand.tokens;
1773
+ const directoryTokens = directoryCommandTokens(tokens);
1774
+ if (String(directoryTokens[0]).replace(/^[({]+/, '') === 'cd') {
1775
+ const args = directoryTokens.slice(1);
1776
+ const hasEndOfOptions = args[0] === '--';
1777
+ const positionals = hasEndOfOptions ? args.slice(1) : args;
1778
+ const path = positionals[0];
1779
+ const directoryEnvironmentRisk = assignmentRiskNames(parsedCommand.assignments)
1780
+ .some((name) => name === 'CDPATH' || name === 'HOME');
1781
+ if (unitInSubshell || parsedCommand.targetUncertain) {
1782
+ targetUncertain = true;
1783
+ pathDialectUncertain ||= shellPathIsAmbiguous(path);
1784
+ } else if (positionals.length !== 1
1785
+ || (!hasEndOfOptions && path?.startsWith('-'))
1786
+ || path === '-'
1787
+ || shellPathHasExpansion(path)) {
1788
+ targetUncertain = true;
1789
+ } else {
1790
+ cwd = resolveShellPath(cwd, path);
1791
+ targetUncertain ||= directoryExecutionUncertain
1792
+ || directoryEnvironmentRisk
1793
+ || cdpathMayRedirect(path)
1794
+ || (unit.operator !== '&&' && unit.operator !== null);
1795
+ pathDialectUncertain ||= parsedCommand.pathDialectUncertain
1796
+ || shellPathIsAmbiguous(path);
1797
+ }
1798
+ } else {
1799
+ if ((unitInSubshell || parsedCommand.targetUncertain)
1800
+ && ['pushd', 'popd'].includes(shellExecutable(directoryTokens[0]))) {
1801
+ targetUncertain = true;
1802
+ return {
1803
+ ...parsedCommand,
1804
+ cwd: commandCwd,
1805
+ environmentRisk,
1806
+ targetEnvironmentRisk: hasCrossTargetEnvironmentRisk(
1807
+ environmentRisk,
1808
+ parsedCommand.tokens[0],
1809
+ ),
1810
+ targetUncertain: commandTargetUncertain,
1811
+ pathDialectUncertain: commandPathDialectUncertain,
1812
+ };
1813
+ }
1814
+ const stackChange = updateDirectoryStack(
1815
+ tokens,
1816
+ cwd,
1817
+ directoryStack,
1818
+ unit.operator,
1819
+ );
1820
+ if (stackChange) {
1821
+ cwd = stackChange.cwd;
1822
+ const directoryEnvironmentRisk = assignmentRiskNames(parsedCommand.assignments)
1823
+ .some((name) => name === 'CDPATH' || name === 'HOME');
1824
+ targetUncertain ||= directoryExecutionUncertain
1825
+ || stackChange.uncertain
1826
+ || directoryEnvironmentRisk;
1827
+ pathDialectUncertain ||= stackChange.pathDialectUncertain;
1828
+ } else if (CURRENT_SHELL_MUTATORS.has(shellExecutable(tokens[0]))) {
1829
+ targetUncertain = true;
1830
+ }
1831
+ }
1832
+ return {
1833
+ ...parsedCommand,
1834
+ cwd: commandCwd,
1835
+ environmentRisk,
1836
+ targetEnvironmentRisk: hasCrossTargetEnvironmentRisk(
1837
+ environmentRisk,
1838
+ parsedCommand.tokens[0],
1839
+ ),
1840
+ targetUncertain: commandTargetUncertain,
1841
+ pathDialectUncertain: commandPathDialectUncertain,
1842
+ };
1843
+ });
1844
+ for (let start = 0; start < units.length;) {
1845
+ let end = start;
1846
+ while (units[end]?.operator === '|') end += 1;
1847
+ if (end > start && pipelineCommandConsumesCode(commands[end]?.tokens)) {
1848
+ const pipelineEnvironmentRisk = unique(commands
1849
+ .slice(start, end + 1)
1850
+ .flatMap((entry) => entry.environmentRisk));
1851
+ const pipelineTargetEnvironmentRisk = commands
1852
+ .slice(start, end + 1)
1853
+ .some((entry) => entry.targetEnvironmentRisk);
1854
+ for (let index = start; index < end; index++) {
1855
+ const payload = literalProducerPayload(commands[index].tokens);
1856
+ if (payload === null) continue;
1857
+ const details = literalCommitDetails(payload, commands[index].cwd);
1858
+ if (details) {
1859
+ details.targetUncertain ||= commands[index].targetUncertain
1860
+ || pipelineEnvironmentRisk.length > 0;
1861
+ details.pathDialectUncertain ||= commands[index].pathDialectUncertain;
1862
+ details.environmentRisk = pipelineEnvironmentRisk;
1863
+ details.targetEnvironmentRisk = pipelineTargetEnvironmentRisk;
1864
+ commits.push(details);
1865
+ }
1866
+ }
1867
+ }
1868
+ start = end + 1;
1869
+ }
1870
+ return commits;
1871
+ }
1872
+
1873
+ function dynamicLiteralCommitInvocations(command, initialCwd) {
1874
+ const commits = [];
1875
+ const variableExecution = (
1876
+ /(?:^|[;&\n])\s*eval\b[^;&\n]*\$(?:[A-Za-z_][A-Za-z0-9_]*|[@*]|[1-9][0-9]*)/m.test(command)
1877
+ || /\b(?:bash|dash|ksh|sh|zsh)\b[^;&\n]*-[A-Za-z]*c[^;&\n]*\$(?:[A-Za-z_][A-Za-z0-9_]*|[@*]|[1-9][0-9]*)/.test(command)
1878
+ || /\bset\s+--[\s\S]*?(?:^|[;&\n])\s*["']?\$(?:@|\*)/m.test(command)
1879
+ );
1880
+ if (variableExecution) {
1881
+ const details = literalCommitDetails(command, initialCwd);
1882
+ if (details) {
1883
+ details.targetUncertain = true;
1884
+ commits.push(details);
1885
+ }
1886
+ }
1887
+
1888
+ const definition = /(?:^|[;&\n])\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(\s*\))?\s*\{([\s\S]*?)\}/gm;
1889
+ for (const match of command.matchAll(definition)) {
1890
+ const tail = command.slice(match.index + match[0].length);
1891
+ const invoked = new RegExp(`(?:^|[;&\\n])\\s*${match[1]}(?:\\s|[;&]|$)`).test(tail);
1892
+ if (!invoked) continue;
1893
+ const parsed = parseGit(match[2], initialCwd);
1894
+ for (const candidate of parsed.commands.filter((entry) => entry.verb === 'commit')) {
1895
+ commits.push({
1896
+ cwd: candidate.cwd,
1897
+ bypassHooks: candidate.bypassHooks || false,
1898
+ environmentRisk: candidate.environmentRisk || [],
1899
+ targetEnvironmentRisk: candidate.targetEnvironmentRisk || false,
1900
+ explicitTargetOption: candidate.explicitTargetOption || false,
1901
+ targetUncertain: candidate.targetUncertain
1902
+ || commandMayChangeShellTarget(command),
1903
+ pathDialectUncertain: candidate.pathDialectUncertain || false,
1904
+ });
1905
+ }
1906
+ }
1907
+ return commits;
1908
+ }
1909
+
1910
+ function commandMayChangeShellTarget(command) {
1911
+ return /(?:^|[;&\n{}])\s*(?:(?:builtin|command)\s+)?(?:(?:alias|autoload|cd|enable|eval|hash|popd|pushd|rehash|setopt|source|unalias|unfunction)\b|\.(?=\s|$))/m
1912
+ .test(String(command || ''));
1913
+ }
1914
+
1915
+ function shellSubshellState(value) {
1916
+ let quote = '';
1917
+ let escaped = false;
1918
+ let depth = 0;
1919
+ let opensSubshell = false;
1920
+ for (const character of String(value || '')) {
1921
+ if (escaped) {
1922
+ escaped = false;
1923
+ } else if (character === '\\' && quote !== "'") {
1924
+ escaped = true;
1925
+ } else if (quote) {
1926
+ if (character === quote) quote = '';
1927
+ } else if (character === "'" || character === '"') {
1928
+ quote = character;
1929
+ } else if (character === '(') {
1930
+ depth += 1;
1931
+ opensSubshell = true;
1932
+ } else if (character === ')') {
1933
+ depth -= 1;
1934
+ }
1935
+ }
1936
+ return { delta: depth, opensSubshell };
1937
+ }
1938
+
1939
+ function literalProducerPayload(tokens) {
1940
+ const executable = shellExecutable(tokens[0]);
1941
+ if (executable !== 'echo' && executable !== 'printf') return null;
1942
+ return tokens.slice(1).join(' ');
1943
+ }
1944
+
1945
+ function shellReadsStandardInput(tokens) {
1946
+ const executable = shellExecutable(tokens[0]);
1947
+ if (!['bash', 'dash', 'ksh', 'sh', 'zsh'].includes(executable)) return false;
1948
+ let forceStdin = false;
1949
+ for (let index = 1; index < tokens.length; index++) {
1950
+ const option = tokens[index];
1951
+ if (option === '--') return forceStdin || index === tokens.length - 1;
1952
+ if (option === '-c' || /^-[^-]*c/.test(option)) return false;
1953
+ if (option === '-s' || /^-[^-]*s/.test(option)) {
1954
+ forceStdin = true;
1955
+ continue;
1956
+ }
1957
+ if (option === '-O' || option === '--rcfile') {
1958
+ index += 1;
1959
+ continue;
1960
+ }
1961
+ if (option.startsWith('-')) continue;
1962
+ return forceStdin;
1963
+ }
1964
+ return true;
1965
+ }
1966
+
1967
+ function pipelineCommandConsumesCode(tokens) {
1968
+ if (shellReadsStandardInput(tokens)) return true;
1969
+ const nested = nestedShellPayload(tokens);
1970
+ if (nested === null) return false;
1971
+ const units = shellUnits(nested);
1972
+ if (units.length === 1) return shellReadsStandardInput(shellWords(units[0].text.trim()));
1973
+ const last = shellWords(units.at(-1).text.trim());
1974
+ if (!shellReadsStandardInput(last)) return false;
1975
+ const first = shellExecutable(shellWords(units[0].text.trim())[0]);
1976
+ return STDIN_RELAYS.has(first);
1977
+ }
1978
+
1979
+ const STDIN_RELAYS = new Set(['awk', 'cat', 'grep', 'head', 'sed', 'tail', 'tee', 'tr']);
1980
+
1981
+ function shellExecutable(value) {
1982
+ return basename(String(value || '').replace(/^[({]+/, '').replace(/[)}]+$/, ''))
1983
+ .toLowerCase()
1984
+ .replace(/\.exe$/, '');
1985
+ }
1986
+
1987
+ // Tilde-user and directory-stack forms need shell state that Node cannot infer.
1988
+ // Git Bash also maps /tmp and /c/... through its MSYS mount table and expands
1989
+ // all tilde targets from shell state that may differ from Node. Reject those
1990
+ // guarded targets rather than inspect the wrong repository and policy.
1991
+ export function shellPathIsAmbiguous(path, platform = process.platform) {
1992
+ const value = String(path || '');
1993
+ // Quote provenance is intentionally not retained by the tokenizer. A
1994
+ // leading tilde may therefore be shell-expanded or literal, and resolving
1995
+ // it here would risk choosing the wrong repository on every platform.
1996
+ if (value.startsWith('~')) return true;
1997
+ // POSIX leaves exactly two leading slashes implementation-defined, while
1998
+ // node:path normalizes them to one on POSIX hosts.
1999
+ if (platform !== 'win32' && /^\/\/(?:[^/]|$)/.test(value)) return true;
2000
+ if (platform !== 'win32') return false;
2001
+ // `C:repo` is drive-relative, not absolute. Its meaning depends on the
2002
+ // process's hidden per-drive cwd, which may differ between Node and MSYS.
2003
+ if (/^[A-Za-z]:(?![\\/])/.test(value)) return true;
2004
+ // A valid UNC target needs both server and share components. Incomplete
2005
+ // forms can be normalized differently by Node, MSYS, and native Git.
2006
+ const uncValue = value.replace(/\\/g, '/');
2007
+ if (/^\/\//.test(uncValue) && !/^\/\/[^/]+\/[^/]+(?:\/|$)/.test(uncValue)) return true;
2008
+ return /^(?:\/(?!\/)|\/{3,})/.test(value);
2009
+ }
2010
+
2011
+ function shellPathHasExpansion(path) {
2012
+ return /[$`*?\[\]{}]/.test(String(path || ''));
2013
+ }
2014
+
2015
+ function cdpathMayRedirect(path) {
2016
+ const value = String(path || '');
2017
+ return Boolean(process.env.CDPATH)
2018
+ && value.length > 0
2019
+ && !isAbsolute(value)
2020
+ && !/^\.\.?(?:[\\/]|$)/.test(value)
2021
+ && !value.startsWith('~');
2022
+ }
2023
+
2024
+ function resolveShellPath(cwd, path) {
2025
+ if (!path || path === '~') return homedir();
2026
+ if (path.startsWith('~/')) return resolve(homedir(), path.slice(2));
2027
+ return resolve(cwd, path);
2028
+ }
2029
+
2030
+ function unwrapCommand(input, initialCwd) {
2031
+ const toks = [...input];
2032
+ const assignments = [];
2033
+ let cwd = initialCwd;
2034
+ let uncertain = false;
2035
+ let pathDialectUncertain = false;
2036
+ let targetUncertain = false;
2037
+ let explicitTargetOption = false;
2038
+ const stripAssignments = () => {
2039
+ while (isAssignment(toks[0])) assignments.push(toks.shift());
2040
+ };
2041
+ const stripPrecommands = () => {
2042
+ for (;;) {
2043
+ if (toks[0] === '-' || toks[0] === 'nocorrect' || toks[0] === 'noglob') {
2044
+ toks.shift();
2045
+ } else if (toks[0] === 'coproc') {
2046
+ toks.shift();
2047
+ targetUncertain = true;
2048
+ } else if (toks[0] === 'builtin') {
2049
+ toks.shift();
2050
+ if (toks[0] === '--') toks.shift();
2051
+ } else if (toks[0] === 'repeat' && toks.length > 1) {
2052
+ toks.splice(0, 2);
2053
+ targetUncertain = true;
2054
+ } else {
2055
+ break;
2056
+ }
2057
+ uncertain = true;
2058
+ stripAssignments();
2059
+ }
2060
+ };
2061
+ stripAssignments();
2062
+ stripPrecommands();
2063
+ while (toks[0] === 'command' || shellExecutable(toks[0]) === 'env' || toks[0] === 'exec') {
2064
+ const wrapperToken = toks.shift();
2065
+ const wrapper = shellExecutable(wrapperToken);
2066
+ if (wrapper === 'env' && wrapperToken !== 'env') {
2067
+ // Pathed/env.exe wrappers are executable indirection, not a shell
2068
+ // builtin identity the parser can authenticate.
2069
+ uncertain = true;
2070
+ targetUncertain = true;
2071
+ }
2072
+ if (wrapper === 'command') {
2073
+ while (toks[0]?.startsWith('-')) {
2074
+ if (toks[0] === '--') {
2075
+ toks.shift();
2076
+ break;
2077
+ }
2078
+ if (toks[0] === '-v' || toks[0] === '-V') {
2079
+ toks.length = 0;
2080
+ break;
2081
+ }
2082
+ uncertain = true;
2083
+ toks.shift();
2084
+ }
2085
+ } else if (wrapper === 'exec') {
2086
+ if (toks[0] === '--') toks.shift();
2087
+ while (toks[0]?.startsWith('-')) {
2088
+ uncertain = true;
2089
+ if (toks[0] === '-a' && toks[1]) toks.splice(0, 2);
2090
+ else toks.shift();
2091
+ }
2092
+ } else {
2093
+ while (toks.length) {
2094
+ if (toks[0] === '-u' || toks[0] === '--unset') {
2095
+ if (toks[1]) assignments.push(`${toks[1]}=<unset>`);
2096
+ toks.splice(0, Math.min(2, toks.length));
2097
+ } else if (toks[0].startsWith('-u') && toks[0].length > 2) {
2098
+ assignments.push(`${toks[0].slice(2)}=<unset>`);
2099
+ toks.shift();
2100
+ } else if (toks[0].startsWith('--unset=')) {
2101
+ assignments.push(`${toks[0].slice('--unset='.length)}=<unset>`);
2102
+ toks.shift();
2103
+ } else if (toks[0] === '-C' || toks[0] === '--chdir') {
2104
+ explicitTargetOption = true;
2105
+ if (!toks[1]) {
2106
+ uncertain = true;
2107
+ toks.shift();
2108
+ } else {
2109
+ targetUncertain ||= shellPathHasExpansion(toks[1]);
2110
+ pathDialectUncertain ||= shellPathIsAmbiguous(toks[1]);
2111
+ cwd = resolveShellPath(cwd, toks[1]);
2112
+ toks.splice(0, 2);
2113
+ }
2114
+ } else if (toks[0].startsWith('--chdir=')) {
2115
+ explicitTargetOption = true;
2116
+ const target = toks[0].slice('--chdir='.length);
2117
+ targetUncertain ||= shellPathHasExpansion(target);
2118
+ pathDialectUncertain ||= shellPathIsAmbiguous(target);
2119
+ cwd = resolveShellPath(cwd, target);
2120
+ toks.shift();
2121
+ } else if (toks[0].startsWith('-C') && toks[0].length > 2) {
2122
+ explicitTargetOption = true;
2123
+ const target = toks[0].slice(2);
2124
+ targetUncertain ||= shellPathHasExpansion(target);
2125
+ pathDialectUncertain ||= shellPathIsAmbiguous(target);
2126
+ cwd = resolveShellPath(cwd, target);
2127
+ toks.shift();
2128
+ } else if (toks[0] === '-P') {
2129
+ uncertain = true;
2130
+ toks.splice(0, Math.min(2, toks.length));
2131
+ } else if (toks[0] === '-S' || toks[0] === '--split-string') {
2132
+ const payload = toks[1];
2133
+ toks.splice(0, Math.min(2, toks.length), ...shellWords(payload || ''));
2134
+ uncertain = true;
2135
+ break;
2136
+ } else if (toks[0].startsWith('--split-string=')) {
2137
+ const payload = toks.shift().slice('--split-string='.length);
2138
+ toks.unshift(...shellWords(payload));
2139
+ uncertain = true;
2140
+ break;
2141
+ } else if (/^-S.+/.test(toks[0])) {
2142
+ const payload = toks.shift().slice(2);
2143
+ toks.unshift(...shellWords(payload));
2144
+ uncertain = true;
2145
+ break;
2146
+ } else if (['-i', '--ignore-environment'].includes(toks[0])) {
2147
+ uncertain = true;
2148
+ toks.shift();
2149
+ } else if (['-0', '--null', '-v', '--debug'].includes(toks[0])) {
2150
+ toks.shift();
2151
+ } else if (toks[0].startsWith('-')) {
2152
+ uncertain = true;
2153
+ toks.shift();
2154
+ }
2155
+ else if (isAssignment(toks[0])) assignments.push(toks.shift());
2156
+ else break;
2157
+ }
2158
+ }
2159
+ stripAssignments();
2160
+ stripPrecommands();
2161
+ }
2162
+ return {
2163
+ tokens: toks,
2164
+ assignments,
2165
+ cwd,
2166
+ uncertain,
2167
+ pathDialectUncertain,
2168
+ targetUncertain,
2169
+ explicitTargetOption,
2170
+ };
2171
+ }
2172
+
2173
+ function possibleGitCommit(words) {
2174
+ const fragments = words.flatMap((word) => word.split(/\s+/).filter(Boolean));
2175
+ const git = fragments.findIndex((word) => isGitExecutable(word.replace(/^\(+/, '')));
2176
+ return git >= 0 && fragments.slice(git + 1).includes('commit');
2177
+ }
2178
+
2179
+ const INDIRECT_EXECUTORS = new Set([
2180
+ 'busybox', 'call', 'chrt', 'cmd', 'doas', 'eval', 'find', 'ionice', 'nice',
2181
+ 'nohup', 'parallel', 'powershell', 'pwsh', 'setsid', 'start', 'start-process',
2182
+ 'stdbuf', 'sudo', 'time', 'timeout', 'watch', 'wsl', 'xargs',
2183
+ 'iex', 'invoke-expression',
2184
+ // Local process wrappers whose visible command may invoke Git. Remote and
2185
+ // container drivers (ssh, docker, podman) are intentionally excluded: they
2186
+ // run Git in a repository where this local guard has no jurisdiction. The
2187
+ // cwd/namespace-changing subset below fails closed before policy lookup.
2188
+ 'su', 'pkexec', 'runuser', 'gosu', 'su-exec',
2189
+ 'strace', 'ltrace', 'valgrind', 'perf', 'gdb', 'taskset', 'numactl', 'prlimit',
2190
+ 'firejail', 'bwrap', 'chroot', 'unshare', 'nsenter', 'setpriv',
2191
+ 'torsocks', 'tsocks', 'proxychains', 'proxychains4',
2192
+ 'systemd-run', 'faketime', 'script',
2193
+ ]);
2194
+
2195
+ // These wrappers can evaluate command text, change cwd, or enter another
2196
+ // filesystem namespace before Git starts. Their target cannot be authenticated
2197
+ // from the caller's repository, even when a literal `git commit` is visible.
2198
+ const UNCERTAIN_TARGET_INDIRECT_EXECUTORS = new Set([
2199
+ 'busybox', 'bwrap', 'call', 'chroot', 'cmd', 'find', 'firejail',
2200
+ 'gdb', 'iex', 'invoke-expression', 'nsenter', 'parallel', 'powershell',
2201
+ 'pwsh', 'runuser', 'script', 'start', 'start-process', 'su', 'sudo',
2202
+ 'systemd-run', 'unshare', 'watch', 'wsl', 'xargs',
2203
+ ]);
2204
+
2205
+ function indirectCommitInvocation(words, initialCwd) {
2206
+ if (!words.length) return false;
2207
+ const executable = shellExecutable(words[0]);
2208
+ const dynamicExecutable = /[$`]/.test(words[0]);
2209
+ if (!dynamicExecutable && !INDIRECT_EXECUTORS.has(executable)) return null;
2210
+ const targetUncertain = UNCERTAIN_TARGET_INDIRECT_EXECUTORS.has(executable)
2211
+ || dynamicExecutable;
2212
+ const details = indirectCommitDetails(words, initialCwd, targetUncertain)
2213
+ || literalCommitDetails(words.slice(1).join(' '), initialCwd);
2214
+ if (details && targetUncertain) details.targetUncertain = true;
2215
+ return details;
2216
+ }
2217
+
2218
+ // Recognized eval flags per interpreter: flags that take a code string to
2219
+ // evaluate. The original set (python/node/perl/ruby/php) is extended with other
2220
+ // common interpreters an agent could use to wrap a commit.
2221
+ const INTERPRETER_EVAL_OPTIONS = new Map([
2222
+ ['node', ['-e', '--eval', '-p', '--print']],
2223
+ ['nodejs', ['-e', '--eval', '-p', '--print']],
2224
+ ['perl', ['-e']],
2225
+ ['ruby', ['-e']],
2226
+ ['php', ['-r']],
2227
+ ['lua', ['-e']],
2228
+ ['luajit', ['-e']],
2229
+ ['rscript', ['-e']],
2230
+ ['r', ['-e']],
2231
+ ['expect', ['-c']],
2232
+ ['julia', ['-e']],
2233
+ ['groovy', ['-e']],
2234
+ ['elixir', ['-e']],
2235
+ ['erl', ['-eval']],
2236
+ ['swipl', ['-g', '-t']],
2237
+ ['guile', ['-c']],
2238
+ ['sbcl', ['--eval']],
2239
+ ]);
2240
+
2241
+ // evalFlagCode finds the code string that follows a recognized eval-style flag
2242
+ // (and the remaining tokens after it), mirroring Git/POSIX short/long/joined
2243
+ // option forms. Returns null when no such flag is present.
2244
+ function evalFlagCode(words, options) {
2245
+ for (let index = 1; index < words.length; index++) {
2246
+ if (options.includes(words[index]) && typeof words[index + 1] === 'string') {
2247
+ return { code: words[index + 1], rest: words.slice(index + 2) };
2248
+ }
2249
+ const longOption = options.find((candidate) => (
2250
+ candidate.startsWith('--') && words[index].startsWith(`${candidate}=`)
2251
+ ));
2252
+ if (longOption) {
2253
+ return { code: words[index].slice(longOption.length + 1), rest: words.slice(index + 1) };
2254
+ }
2255
+ const shortOption = options.find((candidate) => (
2256
+ candidate.length === 2 && words[index].startsWith(candidate) && words[index].length > 2
2257
+ ));
2258
+ if (shortOption) {
2259
+ return { code: words[index].slice(shortOption.length), rest: words.slice(index + 1) };
2260
+ }
2261
+ }
2262
+ return null;
2263
+ }
2264
+
2265
+ // Programs whose -e/-c/-r argument is a pattern or script text, NOT executed
2266
+ // code. Used to keep the generic eval-flag fallback from false-positiving on
2267
+ // commands like `grep -e "system git commit"`.
2268
+ const NON_EXECUTING_EVAL_PROGRAMS = new Set([
2269
+ 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'ack', 'git', 'find', 'cat', 'echo', 'printf',
2270
+ ]);
2271
+
2272
+ function interpretedCommitInvocation(words, initialCwd) {
2273
+ if (!words.length) return null;
2274
+ const executable = shellExecutable(words[0]);
2275
+
2276
+ const options = /^python(?:\d+(?:\.\d+)*)?$/.test(executable) ? ['-c']
2277
+ : INTERPRETER_EVAL_OPTIONS.get(executable);
2278
+ if (options) {
2279
+ const found = evalFlagCode(words, options);
2280
+ if (found) {
2281
+ return literalCommitDetails(found.code, initialCwd)
2282
+ || interpretedArgumentCommitInvocation(found.code, found.rest, initialCwd);
2283
+ }
2284
+ }
2285
+
2286
+ // awk family: the program is the first positional argument (often quoted),
2287
+ // not a flag, and it IS evaluated (system()/getline pipe can run commands).
2288
+ if (['awk', 'gawk', 'mawk', 'nawk'].includes(executable)) {
2289
+ let skippingValue = false;
2290
+ for (let index = 1; index < words.length; index++) {
2291
+ const arg = words[index];
2292
+ if (skippingValue) { skippingValue = false; continue; }
2293
+ if (arg === '--') {
2294
+ const program = words[index + 1];
2295
+ return program ? literalCommitDetails(program, initialCwd) : null;
2296
+ }
2297
+ if (/^-[Ffv]/.test(arg) && arg.length === 2) { skippingValue = true; continue; }
2298
+ if (arg.startsWith('-')) continue;
2299
+ return literalCommitDetails(arg, initialCwd);
2300
+ }
2301
+ }
2302
+
2303
+ // Generic fallback: an unrecognized <prog> <eval-flag> <code> whose code
2304
+ // both executes something and references a git commit. Catches future
2305
+ // interpreters (janet, deno --eval, bun -e) without enumerating each. The
2306
+ // execution-indicator requirement + non-executing denylist avoids false
2307
+ // positives such as `grep -e "git commit"`.
2308
+ if (!NON_EXECUTING_EVAL_PROGRAMS.has(executable)) {
2309
+ const generic = evalFlagCode(words, ['-e', '-c', '--eval', '--exec', '-p', '--print', '-r']);
2310
+ if (generic && /\b(?:argv|child_process|exec|popen|spawn|subprocess|system)\b/.test(generic.code)) {
2311
+ const details = literalCommitDetails(generic.code, initialCwd)
2312
+ || interpretedArgumentCommitInvocation(generic.code, generic.rest, initialCwd);
2313
+ if (details) return details;
2314
+ }
2315
+ }
2316
+
2317
+ return null;
2318
+ }
2319
+
2320
+ function interpretedArgumentCommitInvocation(code, args, initialCwd) {
2321
+ if (!/\b(?:argv|child_process|exec|popen|spawn|subprocess|system)\b/.test(code)) return null;
2322
+ return literalCommitDetails(args.join(' '), initialCwd);
2323
+ }
2324
+
2325
+ function literalCommitDetails(value, initialCwd) {
2326
+ const fragments = String(value || '')
2327
+ .split(/[\s'"`,;()[\]{}]+/)
2328
+ .filter(Boolean);
2329
+ return indirectCommitDetails(['literal', ...fragments], initialCwd, true);
2330
+ }
2331
+
2332
+ function indirectCommitDetails(words, initialCwd, allowImplicitGit = false) {
2333
+ const fragments = words.slice(1).flatMap((word) => word.split(/\s+/).filter(Boolean));
2334
+ const commit = fragments.findIndex((word) => /^commit(?:[\s,]|$)/.test(word));
2335
+ if (commit < 0) return null;
2336
+ const git = fragments.findIndex((word, index) => (
2337
+ index < commit && isGitExecutable(word.replace(/^\(+/, '').replace(/\)+$/, ''))
2338
+ ));
2339
+ if (git < 0 && !allowImplicitGit) return null;
2340
+ let cwd = initialCwd;
2341
+ let targetUncertain = git < 0;
2342
+ let pathDialectUncertain = false;
2343
+ let explicitTargetOption = false;
2344
+ let forceTargetUncertain = false;
2345
+ const beforeCommit = fragments.slice(0, commit);
2346
+ const env = beforeCommit.findIndex((candidate, index) => (
2347
+ index < git && shellExecutable(candidate.replace(/^\(+/, '')) === 'env'
2348
+ ));
2349
+ for (let index = 0; index < beforeCommit.length; index++) {
2350
+ const word = beforeCommit[index].replace(/^\(+/, '');
2351
+ let path = null;
2352
+ if (word === 'cd' && beforeCommit[index + 1]) {
2353
+ path = beforeCommit[++index].replace(/\)+$/, '');
2354
+ } else if (
2355
+ env >= 0
2356
+ && index > env
2357
+ && index < git
2358
+ && (word === '-C' || word === '--chdir')
2359
+ && beforeCommit[index + 1]
2360
+ ) {
2361
+ explicitTargetOption = true;
2362
+ path = beforeCommit[++index].replace(/\)+$/, '');
2363
+ } else if (env >= 0 && index > env && index < git && word.startsWith('--chdir=')) {
2364
+ explicitTargetOption = true;
2365
+ path = word.slice('--chdir='.length).replace(/\)+$/, '');
2366
+ } else if (env >= 0 && index > env && index < git && word.startsWith('-C') && word.length > 2) {
2367
+ explicitTargetOption = true;
2368
+ path = word.slice(2).replace(/\)+$/, '');
2369
+ } else if (index > git && word === '-C' && beforeCommit[index + 1]) {
2370
+ explicitTargetOption = true;
2371
+ path = beforeCommit[++index].replace(/\)+$/, '');
2372
+ } else if (index > git && word.startsWith('-C') && word.length > 2) {
2373
+ explicitTargetOption = true;
2374
+ path = word.slice(2).replace(/\)+$/, '');
2375
+ } else if (index > git && (
2376
+ word === '--git-dir'
2377
+ || word === '--work-tree'
2378
+ ) && beforeCommit[index + 1]) {
2379
+ explicitTargetOption = true;
2380
+ forceTargetUncertain = true;
2381
+ index += 1;
2382
+ } else if (index > git && (
2383
+ word.startsWith('--git-dir=')
2384
+ || word.startsWith('--work-tree=')
2385
+ )) {
2386
+ explicitTargetOption = true;
2387
+ forceTargetUncertain = true;
2388
+ }
2389
+ if (path === null) continue;
2390
+ if (shellPathHasExpansion(path)) {
2391
+ targetUncertain = true;
2392
+ continue;
2393
+ }
2394
+ pathDialectUncertain ||= shellPathIsAmbiguous(path);
2395
+ cwd = resolveShellPath(cwd, path);
2396
+ }
2397
+ return {
2398
+ cwd,
2399
+ targetUncertain: targetUncertain || forceTargetUncertain,
2400
+ pathDialectUncertain,
2401
+ explicitTargetOption,
2402
+ };
2403
+ }
2404
+
2405
+ function isAssignment(token) {
2406
+ return typeof token === 'string' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
2407
+ }
2408
+
2409
+ function updatePersistentEnvironment(words, risks) {
2410
+ if (!words.length) return true;
2411
+ if (words.every(isAssignment)) {
2412
+ for (const name of assignmentRiskNames(words)) risks.add(name);
2413
+ return true;
2414
+ }
2415
+ if (['declare', 'export', 'local', 'readonly', 'typeset'].includes(words[0])) {
2416
+ const declarations = words.slice(1).flatMap((word) => {
2417
+ if (isAssignment(word)) return [word];
2418
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(word)) return [`${word}=<declared>`];
2419
+ return [];
2420
+ });
2421
+ for (const name of assignmentRiskNames(declarations)) risks.add(name);
2422
+ return true;
2423
+ }
2424
+ if (words[0] === 'unset') {
2425
+ for (const name of words.slice(1).filter((word) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(word))) {
2426
+ for (const risk of assignmentRiskNames([`${name}=<unset>`])) risks.add(risk);
2427
+ }
2428
+ return true;
2429
+ }
2430
+ return false;
2431
+ }
2432
+
2433
+ function gitConfigAssignmentsBypass(assignments) {
2434
+ return assignments.some((assignment) => {
2435
+ const separator = assignment.indexOf('=');
2436
+ const key = assignment.slice(0, separator).toUpperCase();
2437
+ if (key === 'GIT_CONFIG_PARAMETERS' || key === 'GIT_CONFIG_COUNT') return true;
2438
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) return true;
2439
+ if ([
2440
+ 'GIT_CONFIG', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM',
2441
+ ].includes(key)) return true;
2442
+ return ['HOME', 'XDG_CONFIG_HOME'].includes(key);
2443
+ });
2444
+ }
2445
+
2446
+ const RUNTIME_ASSIGNMENTS = new Set([
2447
+ 'BASH_ENV', 'CDPATH', 'ENV', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR',
2448
+ 'EDITOR', 'GIT_DIR', 'GIT_EDITOR', 'GIT_EXEC_PATH', 'GIT_INDEX_FILE',
2449
+ 'GIT_OBJECT_DIRECTORY', 'GIT_SEQUENCE_EDITOR', 'GIT_WORK_TREE', 'IFS',
2450
+ 'LD_LIBRARY_PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'NODE_PATH',
2451
+ 'NODE_REPL_EXTERNAL_MODULE', 'PATH', 'SHELLOPTS', 'VISUAL', 'ZDOTDIR',
2452
+ ]);
2453
+
2454
+ const CROSS_TARGET_ASSIGNMENTS = new Set([
2455
+ 'BASH_ENV', 'CDPATH', 'ENV', 'GIT_COMMON_DIR', 'GIT_DIR', 'GIT_EXEC_PATH',
2456
+ 'GIT_WORK_TREE', 'HOME', 'ZDOTDIR',
2457
+ 'LD_LIBRARY_PATH', 'LD_PRELOAD',
2458
+ ]);
2459
+
2460
+ function hasCrossTargetEnvironmentRisk(names, executable) {
2461
+ return names.some((name) => (
2462
+ CROSS_TARGET_ASSIGNMENTS.has(name)
2463
+ || name.startsWith('DYLD_')
2464
+ || (name === 'PATH' && canResolveGitAlias(executable))
2465
+ ));
2466
+ }
2467
+
2468
+ function assignmentRiskNames(assignments) {
2469
+ const risky = [];
2470
+ for (const assignment of assignments) {
2471
+ const separator = assignment.indexOf('=');
2472
+ const key = assignment.slice(0, separator).toUpperCase();
2473
+ if (
2474
+ RUNTIME_ASSIGNMENTS.has(key)
2475
+ || key.startsWith('DYLD_')
2476
+ || gitConfigAssignmentsBypass([assignment])
2477
+ ) risky.push(key);
2478
+ }
2479
+ return unique(risky);
2480
+ }
2481
+
2482
+ function unique(values) {
2483
+ return [...new Set(values)];
2484
+ }
2485
+
2486
+ // Minimal shell tokenizer for recognizing Git commands without executing or
2487
+ // expanding them. It preserves whitespace inside quotes and handles ordinary
2488
+ // backslash escapes; shell expansion remains intentionally out of scope.
2489
+ function shellWords(s) {
2490
+ const out = [];
2491
+ let word = '';
2492
+ let started = false;
2493
+ let quote = '';
2494
+ let escaped = false;
2495
+ for (const c of s) {
2496
+ if (escaped) {
2497
+ // A backslash-newline outside single quotes is a shell line
2498
+ // continuation: drop both characters instead of folding the newline
2499
+ // into the word. Folding it would split a flag/config key (e.g.
2500
+ // `--no-\<LF>verify` or `core.hook\<LF>sPath`) and hide a bypass.
2501
+ if (c === '\n') {
2502
+ escaped = false;
2503
+ } else {
2504
+ // Inside double quotes, POSIX shells preserve a backslash
2505
+ // unless it escapes $, `, ", or another backslash. Keeping it
2506
+ // here is load-bearing for native Windows target paths.
2507
+ if (quote === '"' && !['$', '`', '"', '\\'].includes(c)) word += '\\';
2508
+ word += c;
2509
+ started = true;
2510
+ escaped = false;
2511
+ }
2512
+ } else if (c === '\\' && quote !== "'") {
2513
+ started = true;
2514
+ escaped = true;
2515
+ } else if (quote) {
2516
+ if (c === quote) quote = '';
2517
+ else {
2518
+ word += c;
2519
+ started = true;
2520
+ }
2521
+ } else if (c === "'" || c === '"') {
2522
+ started = true;
2523
+ quote = c;
2524
+ } else if (/\s/.test(c)) {
2525
+ if (started) {
2526
+ out.push(word);
2527
+ word = '';
2528
+ started = false;
2529
+ }
2530
+ } else {
2531
+ word += c;
2532
+ started = true;
2533
+ }
2534
+ }
2535
+ if (escaped) {
2536
+ word += '\\';
2537
+ started = true;
2538
+ }
2539
+ if (started) out.push(word);
2540
+ return out;
2541
+ }
2542
+
2543
+ function reasonParts(blocks) {
2544
+ return blocks.map(
2545
+ (f) => (f.path ? `${visible(f.path)} (${f.reason})` : f.reason) + ` [${f.ruleId}]`
2546
+ );
2547
+ }
2548
+
2549
+ function denyReason(blocks) {
2550
+ return (
2551
+ `aimhooman blocked this: ${reasonParts(blocks).join('; ')}. ` +
2552
+ "Unstage it with 'git restore --staged <path>' and keep it out of Git. AI works, hoomans ship."
2553
+ );
2554
+ }
2555
+
2556
+ // advisoryReason explains a clean/compliance hygiene advisory without implying
2557
+ // that an absent Git-boundary hook will remove the path automatically.
2558
+ function advisoryReason(blocks, guarded, bypassContext) {
2559
+ const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
2560
+ const what = paths.length ? paths.map(visible).join(', ') : 'the flagged content';
2561
+ const parts = reasonParts(blocks).join('; ');
2562
+ if (bypassContext) {
2563
+ return `aimhooman advisory: ${bypassContext}; ${what} cannot be assumed to be automatically removed. Run the commit separately without the bypass, or unstage it before committing. (${parts})`;
2564
+ }
2565
+ if (guarded) {
2566
+ return `aimhooman advisory: the pre-commit guard will keep ${what} out automatically (${parts}).`;
2567
+ }
2568
+ return `aimhooman advisory: ${what} matches policy (${parts}). Unstage it or run 'aimhooman init' to install the Git-boundary guard.`;
2569
+ }
2570
+
2571
+ function emitDecision(decision, reason) {
2572
+ const hookSpecificOutput = {
2573
+ hookEventName: 'PreToolUse',
2574
+ permissionDecision: decision,
2575
+ permissionDecisionReason: reason,
2576
+ };
2577
+ if (decision === 'allow') hookSpecificOutput.additionalContext = reason;
2578
+ emit({
2579
+ permissionDecision: decision,
2580
+ permissionDecisionReason: reason,
2581
+ hookSpecificOutput,
2582
+ });
2583
+ return 0;
2584
+ }
2585
+
2586
+ function emit(obj) {
2587
+ process.stdout.write(JSON.stringify(obj));
2588
+ }
2589
+
2590
+ function readStdin() {
2591
+ return new Promise((resolve, reject) => {
2592
+ if (process.stdin.isTTY) {
2593
+ resolve('');
2594
+ return;
2595
+ }
2596
+ let data = '';
2597
+ process.stdin.setEncoding('utf8');
2598
+ process.stdin.on('data', (c) => (data += c));
2599
+ process.stdin.on('end', () => resolve(data));
2600
+ process.stdin.on('error', reject);
2601
+ });
2602
+ }