codeep 3.3.3 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
package/dist/utils/git.js CHANGED
@@ -1,39 +1,1753 @@
1
1
  import { execSync, execFileSync, spawnSync } from 'child_process';
2
- import { existsSync } from 'fs';
3
- import { join } from 'path';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
3
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from 'path';
4
+ // ─── hardened git invocation ─────────────────────────────────────────────────
4
5
  /**
5
- * Check if current directory is a git repository
6
+ * Config keys that make git RUN a command and that no sane global setup
7
+ * depends on, each paired with the value that neutralises it. These go on
8
+ * EVERY git call regardless of where the setting came from — and, unlike the
9
+ * scope-aware layer below, regardless of WHICH repository the call ends up
10
+ * in, which is what makes them the last line for a `git -C vendor/lib` or a
11
+ * `cd vendor/lib && git …` the scan never read (see `core.fsmonitor`).
12
+ *
13
+ * Every one of them is attacker-controlled, because a repository carries its
14
+ * own `.git/config`: cloning (or being handed) a folder someone else prepared
15
+ * is enough, and so is a prompt injection that gets `write_file` pointed at
16
+ * `.git/config`. Git then executes the command on the next perfectly ordinary
17
+ * call — `git status` for the status line already does it.
18
+ *
19
+ * They are applied through the `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` /
20
+ * `GIT_CONFIG_VALUE_n` environment form, which git treats exactly like
21
+ * `-c key=value`: highest precedence, so it beats the repo's config, and it
22
+ * needs no change to the argv each call site builds.
23
+ *
24
+ * Keys whose value a user legitimately sets globally — `gpg.program`,
25
+ * `credential.helper`, `filter.lfs.*`, `core.sshCommand` — are NOT here.
26
+ * Blanketing those breaks git-lfs, signing and `git push` for everybody to
27
+ * stop a repository nobody has. They are handled by repoSuppliedOverrides(),
28
+ * which neutralises them only where the repository itself set them.
6
29
  */
7
- export function isGitRepository(cwd = process.cwd()) {
30
+ const GIT_EXECUTING_CONFIG = [
31
+ // Run on every index refresh — `git status`, `git diff`, `git add`,
32
+ // `git commit`. This is the one that fires at startup, because the status
33
+ // line calls getGitStatus() before the user has typed anything.
34
+ //
35
+ // Blanket rather than scope-aware, and yes, that overrides a user who
36
+ // configured watchman globally — they lose the watchman speed-up on git
37
+ // run through Codeep, nothing else. The reason is NOT that the scan cannot
38
+ // be trusted; it fails closed now (see listGitConfig). It is that the scan
39
+ // is tied to ONE directory, and a git process can be aimed somewhere else:
40
+ // `git -C vendor/lib status`, or a shell line that runs `cd vendor/lib &&
41
+ // git status` — which shellCommandEnv() cannot parse and does not pretend
42
+ // to. In a nested checkout the scan never read, these always-on pairs are
43
+ // the ONLY layer left. Proven with git 2.54: with a hostile `core.fsmonitor`
44
+ // and a hostile `filter.h.clean` in `vendor/lib`, `cd vendor/lib && git
45
+ // status` under this environment ran the filter and did NOT run the
46
+ // fsmonitor. So the highest-frequency executing key stays blanket, and the
47
+ // ones `GIT_CONFIG_*` cannot wildcard (`filter.*`) are the honest gap
48
+ // shellCommandEnv() documents.
49
+ ['core.fsmonitor', 'false'],
50
+ // The pager is spawned whenever git's stdout is a tty. Ours are pipes today,
51
+ // but that is a property of each call site, not something to depend on.
52
+ ['core.pager', 'cat'],
53
+ // Launched when a commit message or a rebase todo needs editing. We always
54
+ // pass `-m`, so `true` (exits 0, writes nothing) costs us nothing — and a
55
+ // git that decided to open an editor on a pipe would hang the UI forever.
56
+ ['core.editor', 'true'],
57
+ ['sequence.editor', 'true'],
58
+ // `git show` on a signed commit verifies the signature by running
59
+ // `gpg.program`, and a hostile repo can both set `log.showSignature=true`
60
+ // and store a commit object carrying a `gpgsig` header. Suppressing the
61
+ // display is enough, and it leaves `gpg.program` itself alone so that users
62
+ // who sign their own commits keep signing them.
63
+ //
64
+ // Blanket for the same reason as `core.fsmonitor` above — a repository the
65
+ // scan never saw is still reached by `git -C sub log` and by `cd sub && git
66
+ // log` — and the cost is only cosmetic: a user who turned
67
+ // `log.showSignature` on GLOBALLY loses the "Good signature from …" lines
68
+ // in a `git log` or `git show` run through Codeep. That is what buys the
69
+ // right to leave `gpg.program` alone, which is the setting that actually
70
+ // has to keep working.
71
+ ['log.showSignature', 'false'],
72
+ // `ext::<command>` URLs run their command as the transport. Nothing in
73
+ // Codeep speaks to a remote, and `ext::` is vanishingly rare in real setups.
74
+ ['protocol.ext.allow', 'never'],
75
+ // Both are read from the repository being served/read and have no global
76
+ // meaning, so there is no user setup to preserve.
77
+ ['uploadpack.packObjectsHook', ''],
78
+ ['core.alternateRefsCommand', ''],
79
+ ];
80
+ /**
81
+ * A `core.hooksPath` that cannot contain a hook file. `/dev/null` is not a
82
+ * directory, so `/dev/null/pre-commit` can never exist — verified on macOS
83
+ * against git 2.54, and it is what the POSIX suites in git.hardening.test.ts
84
+ * assert.
85
+ *
86
+ * The win32 arm is the reserved device name `NUL`, chosen because no
87
+ * directory can shadow a reserved name. It is NOT verified — nothing in this
88
+ * repository runs git on Windows, and the suites below skip there — so it is
89
+ * the best available guess rather than a proven no-hooks path.
90
+ *
91
+ * What that costs if the guess is wrong is worth naming, because it is small:
92
+ * `noHooks` is only ever passed for the commands Codeep runs BY ITSELF
93
+ * (status, diff, rev-parse, show, ls-files, log), none of which git runs a
94
+ * hook for in the first place. A `NUL` that resolved to an openable directory
95
+ * would therefore re-enable nothing that the commands the user triggers do
96
+ * not already run deliberately. Whoever verifies it should replace this with
97
+ * a real empty directory rather than another reserved name.
98
+ */
99
+ const NO_HOOKS_PATH = process.platform === 'win32' ? 'NUL' : '/dev/null';
100
+ /**
101
+ * Raised instead of handing git an environment that the config scan below
102
+ * could not finish building. Every caller in this file catches it and reports
103
+ * `error.message`, which is written for the user rather than for a log.
104
+ */
105
+ export class GitHardeningError extends Error {
106
+ constructor(message) {
107
+ super(message);
108
+ this.name = 'GitHardeningError';
109
+ }
110
+ }
111
+ /** One wording for every refusal, so the user learns to recognise one. */
112
+ function refuse(cwd, detail) {
113
+ return new GitHardeningError(`Refusing to run git in ${cwd}: ${detail}`);
114
+ }
115
+ /**
116
+ * How much `git config --list` output the scan accepts.
117
+ *
118
+ * The old 4MB was a hole rather than a limit: a repository that padded its
119
+ * own `.git/config` past it made execFileSync throw ENOBUFS, the scan's
120
+ * `catch { return null }` reported that as "this repository set nothing", and
121
+ * `filter.hostile.clean` then ran on the next `git status` (proven, with ~5MB
122
+ * of padding). Two things fix it — the scan now fails the CALL instead of
123
+ * failing open, and the buffer is far past anything an honest config reaches
124
+ * (a real `.git/config` is a few kilobytes). Raising it further would add no
125
+ * safety, only more bytes to read in a repository built to waste them.
126
+ */
127
+ const SCAN_MAX_BUFFER = 16 * 1024 * 1024;
128
+ /**
129
+ * How much `git ls-files -s` output the index listing accepts — see
130
+ * indexGitlinkPaths(), the one call that uses it.
131
+ *
132
+ * Sharing SCAN_MAX_BUFFER with it was the bug, and an unusually bad one: the
133
+ * config scan's ceiling is about a `.git/config` an attacker PADDED, where
134
+ * 16MB is already absurd, while the index listing's is about how many files
135
+ * a repository has, where 16MB is roughly 100k paths — a real monorepo, not a
136
+ * hostile one. Past it execFileSync threw ENOBUFS and the catch below turned
137
+ * that into a hardening refusal, so the whole git integration switched itself
138
+ * off in the repositories that need it most, and no fixture in the suite was
139
+ * big enough to see it (reproduced: a 22k-entry index, 21.7MB of listing,
140
+ * refused every git call).
141
+ *
142
+ * 256MB is a ceiling no honest index reaches — ~1.7M paths at the ~150 bytes
143
+ * a record costs here, several times the largest repositories git serves
144
+ * without a virtual filesystem — and it is a transient allocation rather than
145
+ * a resident cost: Node only ever holds what the child actually wrote, which
146
+ * for this repository is 14KB and for a 100k-file monorepo 16MB. The listing
147
+ * is read once per hardenedGitEnv() call and dropped.
148
+ *
149
+ * It is still a ceiling rather than Infinity, because the answer above it has
150
+ * to be an error and not an out-of-memory kill of the whole CLI.
151
+ */
152
+ const INDEX_MAX_BUFFER = 256 * 1024 * 1024;
153
+ /**
154
+ * How long the scan may take.
155
+ *
156
+ * Generous on purpose. The scan costs ~9ms, and the old 2s budget was still
157
+ * blown occasionally by a full test suite running with file parallelism —
158
+ * which used to mean "silently unhardened" and now means "git refuses to
159
+ * run", so a tight budget buys nothing and costs flakes. A budget still has
160
+ * to exist: a config whose `include.path` names a FIFO blocks its reader
161
+ * forever. That config stalls every REAL git call in the repository in
162
+ * exactly the same way, so this bounds our scan, not the problem.
163
+ */
164
+ const SCAN_TIMEOUT_MS = 10_000;
165
+ /**
166
+ * The scope this file gives entries read out of a submodule's own config.
167
+ * Not one of git's own scope words — git never prints these entries at the
168
+ * superproject at all, which is the whole reason listSubmoduleConfig() exists.
169
+ */
170
+ const SUBMODULE_SCOPE = 'codeep-submodule';
171
+ /** Scopes that mean "the repository said so", i.e. attacker-controlled. */
172
+ function isRepoScope(scope) {
173
+ // `worktree` belongs next to `local`: `.git/config.worktree` ships inside
174
+ // `.git` exactly as `.git/config` does, and git starts honouring it the
175
+ // moment the repository sets `extensions.worktreeConfig`. Accepting only
176
+ // `local` left every key written with `git config --worktree` live.
177
+ return scope === 'local' || scope === 'worktree' || scope === SUBMODULE_SCOPE;
178
+ }
179
+ /**
180
+ * One POSIX shell word. Single quotes make every byte inside literal, and the
181
+ * only escape a single-quoted string needs is for `'` itself. Used for the
182
+ * diff-driver message below, whose text contains a key the REPOSITORY chose.
183
+ */
184
+ function shellQuote(text) {
185
+ return `'${text.replace(/'/g, `'\\''`)}'`;
186
+ }
187
+ /**
188
+ * A value the REPOSITORY chose, rendered for a one-line message.
189
+ *
190
+ * A config value may be megabytes long and may carry newlines (git accepts a
191
+ * `\`-continued value), and these strings end up on the status line and in a
192
+ * `printf` git runs through a shell. Folding the whitespace and capping the
193
+ * length keeps the sentence a sentence; the user has the key and the
194
+ * `--unset` either way, so nothing actionable is lost by truncating.
195
+ */
196
+ function describeValue(value) {
197
+ const oneLine = value.replace(/\s+/g, ' ').trim();
198
+ return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine;
199
+ }
200
+ /**
201
+ * The `git config --unset` that actually reaches this entry.
202
+ *
203
+ * A plain `git config --unset <key>` writes the repository it is run in, so
204
+ * for a key that came out of `.git/modules/<name>/config` it clears nothing
205
+ * and exits 5 ("no such section") or 0 — and the user, who was told to run
206
+ * it at the superproject, sees no error and meets the same refusal on the
207
+ * next call. Reproduced against git 2.54. `-C <worktree>` is preferred over
208
+ * `-f <file>` when the submodule is checked out: it is the form a user can
209
+ * read without knowing where git hides a submodule's git directory.
210
+ *
211
+ * `--worktree` for a key in `config.worktree`, for the same reason and with
212
+ * the same symptom: a plain `git config --unset` writes the LOCAL file, so
213
+ * against a key that lives in `config.worktree` it exits 5 ("no such
214
+ * section"), clears nothing, and the user meets the identical refusal on the
215
+ * next call. Reproduced with git 2.54, both scopes. The `-f <file>` form
216
+ * below needs no flag — it names the file outright.
217
+ */
218
+ function unsetCommand(entry) {
219
+ // `worktree` is how git's own `--show-scope` labels the superproject's
220
+ // `.git/config.worktree`; the submodule pass has no scope word of its own
221
+ // for it, so there the file it came from is what says which one it is.
222
+ const worktreeScope = entry.scope === 'worktree' || entry.file?.endsWith(`${sep}config.worktree`) === true ? ' --worktree' : '';
223
+ if (entry.scope !== SUBMODULE_SCOPE)
224
+ return `git config${worktreeScope} --unset ${entry.key}`;
225
+ if (entry.worktree)
226
+ return `git -C ${entry.worktree} config${worktreeScope} --unset ${entry.key}`;
227
+ return `git config -f ${entry.file} --unset ${entry.key}`;
228
+ }
229
+ /** Who set this entry, for "… the diff driver <this> configured in <key>". */
230
+ function describeSetter(entry) {
231
+ if (entry.scope !== SUBMODULE_SCOPE)
232
+ return 'this repository';
233
+ if (entry.worktree)
234
+ return `the submodule at ${entry.worktree}`;
235
+ return `a submodule of this repository (${entry.file})`;
236
+ }
237
+ /** Whose config this entry is, for "<this> sets <key>, which git runs …". */
238
+ function describeConfig(entry) {
239
+ if (entry.scope !== SUBMODULE_SCOPE)
240
+ return "this repository's own git config";
241
+ if (entry.worktree)
242
+ return `the git config of the submodule at ${entry.worktree}`;
243
+ return `the git config of a submodule of this repository (${entry.file})`;
244
+ }
245
+ /**
246
+ * The value that replaces a repo-chosen `diff.external` / `diff.<d>.command`:
247
+ * it names the key and the `git config --unset` that fixes it, then fails.
248
+ *
249
+ * `false` rather than `exit 1` because git appends the diff's pathnames —
250
+ * `sh -c '<value> "$@"' <value> <path> <old> …` — and `exit` treats them as
251
+ * extra operands, while `false` ignores them and still exits non-zero. Git
252
+ * then stops with `fatal: external diff died`, exit 128, so nothing has
253
+ * silently fallen back to git's own diff either.
254
+ */
255
+ function diffDriverRefusalCommand(entry) {
256
+ const message = `codeep: refusing to run the diff driver ${describeSetter(entry)} configured in ${entry.key}. ` +
257
+ `Remove it (${unsetCommand(entry)}) if you trust this repository.`;
258
+ return `printf '%s\\n' ${shellQuote(message)} >&2; false`;
259
+ }
260
+ /**
261
+ * The value that replaces a repo-chosen alias: it says which alias, that
262
+ * Codeep does not run repository-defined aliases, and what to run instead.
263
+ *
264
+ * The previous value was a bare `!false`, which exits 1 with nothing on
265
+ * either stream — so an agent following a repository's own README (`git
266
+ * sync`, `git st`) saw a command fail for no stated reason and had no way to
267
+ * work out that Codeep had disabled it. Same shape as
268
+ * diffDriverRefusalCommand() above, for the same reasons: git appends the
269
+ * user's extra arguments to the alias body (`sh -c "<body> 'arg1'"`), and
270
+ * `false` ignores operands while `exit` would choke on them.
271
+ *
272
+ * The leading `!` is what makes git treat the value as a shell command rather
273
+ * than as git arguments. Both the alias NAME and its value are the
274
+ * repository's own text and go through shellQuote() inside one single-quoted
275
+ * message, for the reason the diff-driver rule spells out: otherwise the
276
+ * warning is the injection.
277
+ */
278
+ function aliasRefusalCommand(entry, name) {
279
+ const value = entry.value;
280
+ // A `!` alias is a shell line; anything else is spliced in front of git's
281
+ // own arguments, so `alias.st = status -sb` means `git status -sb`. Saying
282
+ // which one it is turns "run the plain git command instead" into something
283
+ // the reader can actually act on.
284
+ const defined = value.startsWith('!')
285
+ ? `the shell command ${describeValue(value.slice(1))}`
286
+ : `git ${describeValue(value)}`;
287
+ const setter = describeSetter(entry);
288
+ const message = `codeep: refusing to run 'git ${name}', an alias ${setter} defined in its own git config. ` +
289
+ `Codeep disables repository-defined aliases; run the plain git command instead — ${setter} ` +
290
+ `defines '${name}' as ${defined}. ` +
291
+ `Remove it (${unsetCommand(entry)}) if you trust this repository.`;
292
+ return `!printf '%s\\n' ${shellQuote(message)} >&2; false`;
293
+ }
294
+ /**
295
+ * The exact `filter.<driver>.{clean,smudge,process}` command lines that the
296
+ * well-known content-filter integrations write into a repository's own
297
+ * config. A repo-scope filter whose value is one of these is left RUNNING;
298
+ * every other one refuses the call (see the filter rule below).
299
+ *
300
+ * These are whole-value comparisons against a frozen list of literals, never
301
+ * a prefix or a substring test, and that is the point rather than a detail.
302
+ * Git runs a filter command through a shell, so `git-lfs clean -- %f; curl
303
+ * https://…|sh` STARTS WITH an allowlisted line and would sail through a
304
+ * `startsWith` check while doing something else entirely; `%f` in the middle
305
+ * of a longer value is the same hole for a substring check. Whole-value
306
+ * equality against literals also means "contains no shell metacharacter" is a
307
+ * property of this list rather than something that has to be re-checked at
308
+ * runtime — and the suite asserts that property so a future entry cannot
309
+ * quietly break it.
310
+ *
311
+ * These are the values with the program named BARE. The same integrations
312
+ * also spell the program as an absolute path, which is the machine's and not
313
+ * a string this file can pin — see isSafeContentFilterCommand(), which takes
314
+ * the basename apart and compares it against this same list.
315
+ *
316
+ * A spelling that is NOT accepted in any form: `"<abs path to python>" -m
317
+ * nbstripout`, which newer nbstripout installers write. Its program is
318
+ * `python`, so there is nothing to recognise in it — the argument tail is
319
+ * what says what it will do, and pinning `-m nbstripout` would pin a
320
+ * mechanism for running any module at all. Those repositories get the
321
+ * refusal and its `--unset`, which is the fail-closed half of the policy
322
+ * working as intended rather than an oversight.
323
+ */
324
+ export const SAFE_CONTENT_FILTER_COMMANDS = new Set([
325
+ // `git lfs install` / `git lfs install --local`. `filter-process` is what
326
+ // current git-lfs writes; `clean`/`smudge` are still written alongside it
327
+ // for versions of git without the process filter.
328
+ 'git-lfs filter-process',
329
+ 'git-lfs filter-process --skip',
330
+ 'git-lfs clean -- %f',
331
+ 'git-lfs smudge -- %f',
332
+ 'git-lfs smudge --skip -- %f',
333
+ // The same lines with the program name quoted — what git-lfs writes on
334
+ // Windows, and what older versions wrote everywhere.
335
+ '"git-lfs" filter-process',
336
+ '"git-lfs" filter-process --skip',
337
+ '"git-lfs" clean -- %f',
338
+ '"git-lfs" smudge -- %f',
339
+ '"git-lfs" smudge --skip -- %f',
340
+ // `git-crypt init` writes the quoted spelling; the bare one is what a
341
+ // hand-written or older config has. `diff` belongs to
342
+ // `diff.git-crypt.textconv` rather than to a filter key, and is listed for
343
+ // completeness — note that the textconv rule still replaces it with `cat`,
344
+ // so a git-crypt diff shows ciphertext. That is a worse diff, not a lost
345
+ // commit, and it is the same trade the textconv rule makes for everyone.
346
+ 'git-crypt clean',
347
+ 'git-crypt smudge',
348
+ 'git-crypt diff',
349
+ '"git-crypt" clean',
350
+ '"git-crypt" smudge',
351
+ '"git-crypt" diff',
352
+ // `git annex init` writes both of these into the repository's own config,
353
+ // and an annex repository is refused without them: git-annex routes every
354
+ // path in `.gitattributes` at `filter.annex`, so the refusal lands on the
355
+ // ordinary `git status` behind the status line. `-- %f` is git-annex's own
356
+ // spelling, placeholder included, exactly as git-lfs's lines above are.
357
+ 'git-annex smudge -- %f',
358
+ 'git-annex clean -- %f',
359
+ // `nbstripout --install`, in the spelling that names the program directly.
360
+ 'nbstripout',
361
+ // nbstripout's own smudge side, and git's documented identity filter. Left
362
+ // out, an nbstripout repository would be refused over its `smudge` even
363
+ // though its `clean` is allowlisted, which would make listing `nbstripout`
364
+ // above pointless. `cat` is a program name with no metacharacters in it and
365
+ // is exactly what `--no-textconv` does elsewhere in this file.
366
+ 'cat',
367
+ ]);
368
+ /**
369
+ * The bytes one word of an allowlisted command line may be spelled with.
370
+ *
371
+ * A whitelist of characters rather than a blacklist of shell metacharacters,
372
+ * because git runs a filter command THROUGH A SHELL and the blacklist is the
373
+ * one that has to be complete. Everything not named here stops the value
374
+ * dead: `;` `&` `|` `$` a backtick, a quote, a newline, a tab — and also the
375
+ * bytes that only look like the allowlisted ones, a U+00A0 no-break space or
376
+ * a U+2011 non-breaking hyphen, which is what makes this the check that
377
+ * answers homoglyphs rather than a separate one. `%` is here for git's own
378
+ * `%f` placeholder and means nothing to sh. `~` is NOT: sh expands it at the
379
+ * start of a word.
380
+ */
381
+ const PLAIN_COMMAND_WORD = /^[A-Za-z0-9._\/%+:=@-]+$/;
382
+ /**
383
+ * Whether a repo-scope `filter.<driver>.{clean,smudge,process}` value is one
384
+ * of the well-known integrations — accepting the spelling that names the
385
+ * program by an ABSOLUTE PATH, which the frozen list above cannot hold.
386
+ *
387
+ * `/usr/local/bin/git-lfs filter-process` is what a `git lfs install` writes
388
+ * on a machine where git-lfs is not the one on PATH, and git-annex writes the
389
+ * same shape. Those are ordinary working repositories, and the whole-value
390
+ * list refused every git call in them — the fail-closed policy landing on the
391
+ * integrations it was written to keep running.
392
+ *
393
+ * What is compared is the program's BASENAME plus the argument tail EXACTLY
394
+ * as the literal spells it, so every property of the list survives the
395
+ * relaxation. `/usr/local/bin/git-lfs clean -- %f; curl …|sh` fails on its
396
+ * bytes before anything is compared; `… clean -- %f --extra` and `…
397
+ * FILTER-PROCESS` produce a tail that is not in the list; a leading command
398
+ * puts something other than an absolute path in the first word. There is no
399
+ * prefix matching anywhere in here, in either half.
400
+ *
401
+ * And the path has to name the program PATH ALREADY RESOLVES that basename
402
+ * to, which is the check that keeps this from being a way in. Without it the
403
+ * repository picks the program: it ships an executable called `git-lfs` — or
404
+ * `cat`, which is on the list with no arguments at all — points the filter at
405
+ * its own checkout, and git runs it. That is not a relaxation of the
406
+ * allowlist, it is the end of it. With it, an absolute path can only name the
407
+ * same file the bare spelling on the list would have run anyway, so the
408
+ * repository gains nothing by writing it out.
409
+ *
410
+ * `env` is the environment the REAL git call will run under, so the PATH
411
+ * asked here is the PATH the shell git spawns would search.
412
+ */
413
+ export function isSafeContentFilterCommand(value, env) {
414
+ if (SAFE_CONTENT_FILTER_COMMANDS.has(value))
415
+ return true;
416
+ // Split on one ASCII space, never on `\s`: JS counts U+00A0 and the rest of
417
+ // Unicode's spaces as whitespace and a shell does not, so `\s` would read
418
+ // `git-lfs<NBSP>filter-process` as two words and compare a string the shell
419
+ // will never see. Anything but a single plain space between words leaves an
420
+ // empty word, or a word PLAIN_COMMAND_WORD rejects.
421
+ const words = value.split(' ');
422
+ if (!words.every(word => PLAIN_COMMAND_WORD.test(word)))
423
+ return false;
424
+ // Absolute only, and with no `..` in it — a relative program resolves
425
+ // against the current directory, which in every call site here is the
426
+ // repository's own working tree, and `..` is how an absolute path becomes
427
+ // a relative one again.
428
+ const program = words[0];
429
+ const parts = program.split('/');
430
+ if (parts[0] !== '' || parts.includes('..'))
431
+ return false;
432
+ const base = parts[parts.length - 1];
433
+ if (!SAFE_CONTENT_FILTER_COMMANDS.has([base, ...words.slice(1)].join(' ')))
434
+ return false;
435
+ let target;
8
436
  try {
9
- const gitDir = join(cwd, '.git');
10
- if (existsSync(gitDir))
11
- return true;
12
- // Check if we're inside a git repo (not necessarily at root)
13
- execSync('git rev-parse --git-dir', { cwd, stdio: 'ignore' });
437
+ target = realpathSync(program);
438
+ }
439
+ catch {
440
+ // A path that is not there is not the program PATH resolves to either,
441
+ // and git would fail on it anyway. Refusing says so with a sentence.
442
+ return false;
443
+ }
444
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
445
+ if (!dir)
446
+ continue;
447
+ try {
448
+ // realpath on both sides: a package manager's `bin` is usually a
449
+ // symlink into its own store, so the two spellings of one program
450
+ // rarely match as strings.
451
+ if (realpathSync(join(dir, base)) === target)
452
+ return true;
453
+ }
454
+ catch {
455
+ // Not in this directory, or not readable through it — the next one
456
+ // answers, and "none of them" is a no.
457
+ }
458
+ }
459
+ return false;
460
+ }
461
+ /**
462
+ * Why a repo-scope content filter stops the call, written for the user.
463
+ *
464
+ * It has to say four things, because each one is a step a user takes next:
465
+ * which driver, what the repository asked git to run, the `--unset` that ends
466
+ * it, and — for the commonest honest case — why a driver that routes nothing
467
+ * in this checkout today is refused all the same. A leftover
468
+ * `filter.nbstripout.*` from an `nbstripout --install` in a repository that
469
+ * has since lost its notebooks is exactly that user, and without the third
470
+ * sentence they read the refusal, see no `.gitattributes` naming the driver,
471
+ * and conclude Codeep is simply wrong.
472
+ *
473
+ * The last sentence is the one that matters most — `git config
474
+ * filter.<d>.required false` is the first hit for "clean filter failed", and
475
+ * it is precisely the change that makes git accept a filter that did not run
476
+ * and write the file's contents unfiltered.
477
+ */
478
+ function contentFilterRefusal(entry, driver) {
479
+ return (`${describeConfig(entry)} sets ${entry.key}, which git runs as a program for every file ` +
480
+ `.gitattributes routes at the "${driver}" filter — here it runs: ${describeValue(entry.value)}. ` +
481
+ `Remove it (${unsetCommand(entry)}) if you trust this repository. ` +
482
+ `That is the fix even if nothing routes a path at "${driver}" in this checkout today: which paths ` +
483
+ `git routes is decided by .gitattributes anywhere in the tree, by .git/info/attributes, and by ` +
484
+ `attr.tree or --attr-source on a single command, so "nothing uses it" is not something Codeep can ` +
485
+ `establish without re-implementing git's own attribute lookup — and it will not guess. ` +
486
+ `Do NOT set filter.${driver}.required false to get past this: that makes git accept a filter that ` +
487
+ `never ran and store the file's contents UNFILTERED, which for an encrypting filter means ` +
488
+ `committing the plaintext.`);
489
+ }
490
+ /**
491
+ * Keys a repository's own config can point at a program, and what neutralises
492
+ * one. Matched against the key as `git config --list` prints it: git
493
+ * lower-cases the section and the variable but preserves the subsection (the
494
+ * driver/tool/remote name) verbatim, so the override has to be emitted with
495
+ * git's own spelling rather than a reconstructed one.
496
+ *
497
+ * Every regex carries `i` for the same reason. `--list` prints
498
+ * `core.sshcommand`, `gpg.ssh.defaultkeycommand` and `interactive.difffilter`,
499
+ * so a rule spelled the way the git documentation spells it matches NOTHING —
500
+ * which is how five rules here once shipped dead, with a repo-scope
501
+ * `gpg.ssh.defaultKeyCommand` still running a script through createCommit.
502
+ * `i` covers the subsection too, which is harmless: the capture still carries
503
+ * git's own bytes, and those are what gets emitted.
504
+ *
505
+ * `GIT_CONFIG_*` cannot wildcard `filter.*`, which is why this is a scan and
506
+ * not more entries in GIT_EXECUTING_CONFIG.
507
+ */
508
+ const REPO_EXECUTING_RULES = [
509
+ {
510
+ // The blocker, and the one content rule that refuses instead of
511
+ // neutralising. `filter.<driver>.clean` runs during the index refresh
512
+ // that `git status` performs, as soon as a tracked `.gitattributes`
513
+ // routes any file at the driver.
514
+ //
515
+ // Emptying the command is NOT a neutral act, because
516
+ // `filter.<driver>.required` defaults to FALSE. With the command emptied
517
+ // and `required` unset — the ordinary case, not an exotic one — git
518
+ // treats the filter as having produced nothing, falls back to the file's
519
+ // own bytes and stores them UNFILTERED. For an encrypting driver that is
520
+ // the plaintext, committed, on the path every repository takes. And
521
+ // emitting `required = false` ourselves to keep a REQUIRED filter from
522
+ // aborting is the same incident on purpose, which is why nothing in this
523
+ // file ever emits it.
524
+ //
525
+ // Leaving `required = true` alone was the previous answer, and it only
526
+ // half-worked: git does abort there (`fatal: <f>: clean filter '<d>'
527
+ // failed`), but with its own bare message, so Codeep's git integration
528
+ // looked broken in exactly the repositories `git lfs install --local`,
529
+ // `git-crypt init` and `nbstripout --install` produce — and the
530
+ // workaround a user googles for that message is `git config
531
+ // filter.<d>.required false`, which walks them into the plaintext commit.
532
+ //
533
+ // So: the well-known integrations are left running (see
534
+ // SAFE_CONTENT_FILTER_COMMANDS), and anything else stops the call with a
535
+ // sentence that names the driver and warns off that workaround. `required`
536
+ // is never read and never written — the answer is the same either way.
537
+ //
538
+ // UNCONDITIONALLY, and that is a decision rather than an oversight. A
539
+ // previous round relaxed this to "refuse only when some attributes file
540
+ // routes a path at the driver", to spare the ordinary repository that
541
+ // still carries a leftover `filter.nbstripout.*` from an `nbstripout
542
+ // --install` and has since lost its notebooks. Answering that question
543
+ // means re-implementing git's attribute resolution, and the copy missed
544
+ // sources git honours: `.git/info/attributes` in a linked worktree,
545
+ // `attr.tree`, `--attr-source`, and a path that is in the index but not
546
+ // on disk. Each miss reads as "inert" and hands the repository arbitrary
547
+ // execution back. A security hotfix is not where that reimplementation
548
+ // gets written, so the check is gone and the refusal is flat: a
549
+ // repo-scope content filter that is not one of the well-known
550
+ // integrations stops the call whether or not anything routes a path at
551
+ // it today. contentFilterRefusal() says so in the message, because the
552
+ // leftover-config user is the one who meets it.
553
+ match: /^filter\.(.+)\.(clean|smudge|process)$/i,
554
+ refuse: (entry, m, env) => isSafeContentFilterCommand(entry.value, env) ? null : contentFilterRefusal(entry, m[1]),
555
+ },
556
+ {
557
+ // `.textconv` renders a file to text before diffing it; `.gitattributes`
558
+ // picks the driver. `cat` is the faithful neutraliser — it is exactly
559
+ // what `--no-textconv` does, the diff comes out as the raw bytes, and it
560
+ // is a program name so no shell metacharacter in it means anything.
561
+ // Emptying it instead broke the whole diff (`error: cannot run :` /
562
+ // `fatal: unable to read files to diff`, exit 128) in every repository
563
+ // that configures a textconv driver at all — a pdf/docx setup is an
564
+ // ordinary thing to have, and `git diff` through execute_command stopped
565
+ // working in it.
566
+ match: /^diff\.(.+)\.textconv$/i,
567
+ neutralise: entry => [[entry.key, 'cat']],
568
+ },
569
+ {
570
+ // `diff.<driver>.command` replaces git's diff engine for the files
571
+ // `.gitattributes` routes at it; `diff.external` does the same for every
572
+ // unclaimed file. Neither has a neutral value — there is no stock program
573
+ // that writes git's own diff — so these FAIL THE CALL CLOSED, which is
574
+ // the right answer for a git command that reached a repo-chosen diff
575
+ // driver at all.
576
+ //
577
+ // Emptying them made git print `error: cannot run :`, which names
578
+ // nothing the user can act on. Git runs these values through a shell
579
+ // (`sh -c '<value> "$@"' …`), so the value can say what happened instead
580
+ // — and `false` swallows the pathnames git appends. The key is spliced
581
+ // in single-quoted, because a `diff."…".command` subsection is the
582
+ // REPOSITORY's text: anything less and the message would be the
583
+ // injection. Git still stops with `fatal: external diff died`, exit 128.
584
+ //
585
+ // Codeep's own diff reads pass `--no-ext-diff --no-textconv` and never
586
+ // get here; this covers `git diff` run through execute_command.
587
+ match: /^diff\.((.+)\.command|external)$/i,
588
+ neutralise: entry => [[entry.key, diffDriverRefusalCommand(entry)]],
589
+ },
590
+ {
591
+ // A custom merge driver runs on every conflicting hunk. Emptying it makes
592
+ // git print `error: cannot run :` and fall back to its built-in merge, so
593
+ // the merge still completes (verified) — noisy, but not destructive.
594
+ match: /^merge\.(.+)\.driver$/i,
595
+ neutralise: entry => [[entry.key, '']],
596
+ },
597
+ {
598
+ // Only reached by `git mergetool` / `git difftool`, which Codeep never
599
+ // runs by itself — but the agent's execute_command can.
600
+ match: /^(mergetool|difftool)\.(.+)\.cmd$/i,
601
+ neutralise: entry => [[entry.key, '']],
602
+ },
603
+ {
604
+ // The gpg cluster. `commit.gpgsign=true` plus a repo-local `gpg.program`
605
+ // runs that program on EVERY commit Codeep makes. Emptying the program
606
+ // alone fails the commit ("gpg failed to sign the data"), so signing is
607
+ // switched off for this call instead: Codeep declines to sign rather than
608
+ // sign through a program the repository chose. A user who signs from
609
+ // their GLOBAL config never matches this rule and keeps signing — that
610
+ // scope check is the whole reason this is safe to do.
611
+ match: /^gpg\.(program|.+\.program|ssh\.defaultKeyCommand)$/i,
612
+ neutralise: entry => [
613
+ [entry.key, ''],
614
+ ['commit.gpgsign', 'false'],
615
+ ['tag.gpgsign', 'false'],
616
+ ['tag.forceSignAnnotated', 'false'],
617
+ ['merge.verifySignatures', 'false'],
618
+ ],
619
+ },
620
+ {
621
+ // `submodule.<name>.update = !command` runs on a recursing checkout.
622
+ // `checkout` is git's own default, so this restores stock behaviour
623
+ // instead of switching submodule recursion off for everyone (which is
624
+ // what a blanket `submodule.recurse=false` would have done to a user who
625
+ // deliberately turned it on).
626
+ match: /^submodule\.(.+)\.update$/i,
627
+ neutralise: entry => (entry.value.startsWith('!') ? [[entry.key, 'checkout']] : []),
628
+ },
629
+ {
630
+ // EVERY repo-scope alias, not only the `!shell command` ones. An alias
631
+ // runs whenever anything asks git for it by name — and "anything"
632
+ // includes an agent following a README that says `git sync`.
633
+ //
634
+ // The `!` check was the hole: a non-`!` alias is spliced into argv in
635
+ // front of the subcommand, so `alias.sync = -c core.fsmonitor=<program>
636
+ // status` makes `git sync` re-enable a key this whole file switched off.
637
+ // Proven with git 2.54 — under `GIT_CONFIG_KEY_0=core.fsmonitor
638
+ // VALUE_0=false`, `git sync` ran the program anyway, because git's own
639
+ // `-c` is read after the GIT_CONFIG_* pairs and wins. One `-c` undoes any
640
+ // pair here, so scanning the alias VALUE for dangerous shapes would be a
641
+ // guessing game against git's argv parser.
642
+ //
643
+ // A repository alias has no legitimate need to survive a Codeep-mediated
644
+ // call: it is a shorthand its author typed into their own shell, never
645
+ // something Codeep or a model has to invoke, and the user's own global
646
+ // aliases are untouched by the scope check.
647
+ //
648
+ // The replacement SAYS SO rather than just failing. `!false` — what this
649
+ // shipped as — exits 1 with empty stdout and empty stderr, so an agent
650
+ // following a repository's own README (`git sync`, `git st`) saw a git
651
+ // command fail with nothing to read and no way to reach "Codeep turned
652
+ // that alias off, run the real command". Same message-printing shape the
653
+ // diff-driver rule already uses, and it still exits 1, so nothing has
654
+ // quietly succeeded either.
655
+ match: /^alias\.(.+)$/i,
656
+ neutralise: (entry, m) => [[entry.key, aliasRefusalCommand(entry, m[1])]],
657
+ },
658
+ {
659
+ // A credential helper whose value starts with `!` is a shell command, run
660
+ // on any authenticated remote operation. Handled specially below, because
661
+ // helpers form a LIST and the only way to drop one entry is to reset the
662
+ // list and re-add the ones worth keeping.
663
+ match: /^credential\.(.+\.)?helper$/i,
664
+ neutralise: () => [],
665
+ },
666
+ {
667
+ // `core.sshCommand` and `core.gitProxy` are spawned to reach a remote;
668
+ // `core.askPass` is spawned to ask for a password. Global values belong to
669
+ // the user's working `git push` and are left alone.
670
+ match: /^core\.(sshCommand|askPass|gitProxy)$/i,
671
+ neutralise: entry => [[entry.key, '']],
672
+ },
673
+ {
674
+ // `remote.<name>.uploadpack` / `.receivepack` name the program git runs
675
+ // at the far end of a fetch or a push — and for a `file://` remote or a
676
+ // plain path, the far end is this machine. Proven with git 2.54 through
677
+ // the hardened executeCommand path: the uploadpack script ran on `git
678
+ // fetch`, the receivepack script on `git push`.
679
+ //
680
+ // This is the one key here that no override reaches. git's remote.c keeps
681
+ // the FIRST value it sees ("more than one uploadpack given, using the
682
+ // first"), and repository config is read before `-c` / `GIT_CONFIG_*` —
683
+ // verified: with the repo value in place, an environment override, a `-c`
684
+ // override and GIT_CONFIG_PARAMETERS all lost, and only the
685
+ // `--upload-pack` command-line flag won, which is not ours to pass. So
686
+ // the call is refused instead.
687
+ //
688
+ // Refused for every git command, not only the ones that reach a remote:
689
+ // hardenedGitEnv() never sees the argv, and narrowing by the remote's URL
690
+ // (a local path and `file://` are the transports that run it here) is
691
+ // defeated by a `url.<base>.insteadOf` that rewrites an ssh URL into a
692
+ // local path. A setup that genuinely needs a custom upload-pack can say
693
+ // so in the user's global config, where it is not the repository talking.
694
+ //
695
+ // Reviewed alongside these and deliberately absent: `remote.<name>.proxy`
696
+ // and `http.proxy` are URLs rather than programs; `core.gitProxy`,
697
+ // `core.sshCommand` and `credential.helper` have their own rules here;
698
+ // `protocol.ext.allow` and `uploadpack.packObjectsHook` are always-on
699
+ // pairs; and `remote.<name>.vcs` only selects a `git-remote-<vcs>` that
700
+ // must already be on PATH, which a repository cannot put there.
701
+ match: /^remote\..+\.(uploadpack|receivepack)$/i,
702
+ refuse: entry => `${describeConfig(entry)} sets ${entry.key}, which git runs as a program on fetch and push. ` +
703
+ 'Git keeps the first value it sees for that key, so no environment override can switch it off. ' +
704
+ `Remove it (${unsetCommand(entry)}) if you trust this repository.`,
705
+ },
706
+ {
707
+ // Piped over the diff that `git add -p` and friends show.
708
+ match: /^interactive\.diffFilter$/i,
709
+ neutralise: entry => [[entry.key, '']],
710
+ },
711
+ {
712
+ // Run by `git interpret-trailers` and by `git commit --trailer`.
713
+ match: /^trailer\.(.+)\.(cmd|command)$/i,
714
+ neutralise: entry => [[entry.key, '']],
715
+ },
716
+ ];
717
+ /** Matches every key the credential-helper rule owns. */
718
+ const CREDENTIAL_HELPER_KEY = /^credential\.(.+\.)?helper$/i;
719
+ /**
720
+ * Whether `<key>=<value>` is a config key that makes git RUN a program.
721
+ *
722
+ * Exported for utils/shell.ts, which has to answer the same question about a
723
+ * `git -c <key>=<value>` an agent typed. Keeping one answer is the point:
724
+ * these are exactly the keys this file spends its length neutralising, and a
725
+ * second hand-written list in the command validator would drift away from
726
+ * this one the first time a rule is added here.
727
+ *
728
+ * Both halves matter. GIT_EXECUTING_CONFIG is the always-on set, so a `-c
729
+ * core.fsmonitor=<program>` would otherwise WIN — git reads its own `-c`
730
+ * after the GIT_CONFIG_* pairs (verified, git 2.54). REPO_EXECUTING_RULES is
731
+ * the scope-aware set, and `-c` is not a scope the scan can see at all.
732
+ */
733
+ export function isExecutingConfigKey(key) {
734
+ const lower = key.toLowerCase();
735
+ if (GIT_EXECUTING_CONFIG.some(([name]) => name.toLowerCase() === lower))
14
736
  return true;
737
+ return REPO_EXECUTING_RULES.some(rule => rule.match.test(key));
738
+ }
739
+ /** An absent value, kept out of the parse loop below. */
740
+ const NO_BYTES = Buffer.alloc(0);
741
+ /** Split a NUL-delimited stream at the BYTE level, without decoding first. */
742
+ function splitOnNul(buf) {
743
+ const parts = [];
744
+ let start = 0;
745
+ for (;;) {
746
+ const nul = buf.indexOf(0, start);
747
+ if (nul === -1) {
748
+ parts.push(buf.subarray(start));
749
+ return parts;
750
+ }
751
+ parts.push(buf.subarray(start, nul));
752
+ start = nul + 1;
753
+ }
754
+ }
755
+ /**
756
+ * Decode one config field, and say whether it survives the trip back.
757
+ *
758
+ * Decoding the whole scan as UTF-8 is what let an invalid-UTF-8 subsection
759
+ * through: a filter driver named with raw non-UTF-8 bytes came back as U+FFFD,
760
+ * the override was emitted under the replacement characters, git matched it
761
+ * against nothing, and the clean filter ran (proven with a marker). Since the
762
+ * replacement is lossy and an environment variable is UTF-8 either way, the
763
+ * only honest answers are "emit git's own bytes" and "refuse".
764
+ */
765
+ function decodeExact(bytes) {
766
+ const text = bytes.toString('utf-8');
767
+ return { text, exact: Buffer.from(text, 'utf-8').equals(bytes) };
768
+ }
769
+ /**
770
+ * Read the config git will actually use for a call in `cwd`, under the
771
+ * environment that call will run with, so the scan and the call agree on
772
+ * which repository and which files they are talking about.
773
+ *
774
+ * `git config --list` itself runs no filter, no hook and no fsmonitor
775
+ * (verified), so this is safe to do before the hardening is complete.
776
+ *
777
+ * Returns null only when git never started — there is then nothing to
778
+ * neutralise, because the real call cannot start either. Every other failure
779
+ * means the scan began and did not finish, and that one THROWS: the caller
780
+ * must not run git with a half-built environment.
781
+ */
782
+ function listGitConfig(cwd, env) {
783
+ let out;
784
+ try {
785
+ out = execFileSync('git', ['config', '--list', '-z', '--show-scope'], {
786
+ cwd,
787
+ env,
788
+ timeout: SCAN_TIMEOUT_MS,
789
+ maxBuffer: SCAN_MAX_BUFFER,
790
+ // stderr is piped rather than inherited so a `fatal: bad config line`
791
+ // reaches the message below instead of the middle of the TUI.
792
+ stdio: ['ignore', 'pipe', 'pipe'],
793
+ });
794
+ }
795
+ catch (error) {
796
+ // No git on PATH, no such `cwd`, no permission to enter it: git never
797
+ // ran, and the real call is about to fail in exactly the same way, so
798
+ // there was nothing here for the scan to protect. Everything else —
799
+ // ENOBUFS on an oversized config, ETIMEDOUT, a config file git refuses to
800
+ // parse (exit 128) — means the scan started and did not finish. That was
801
+ // the fail-open hole, and it now stops the call.
802
+ const err = error;
803
+ if (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'ENOTDIR')
804
+ return null;
805
+ const detail = String(err.stderr ?? '').trim() || err.code || `exit ${err.status ?? '?'}`;
806
+ throw refuse(cwd, `its git config could not be read (${detail}), so Codeep cannot tell whether that config ` +
807
+ 'names a program for git to run.');
808
+ }
809
+ return parseConfigList(out);
810
+ }
811
+ /** How `--show-origin` spells a config file. Anything else it can print —
812
+ * `command line:`, `standard input:`, `blob:<sha>` — is not a file we can
813
+ * point a `git config -f` at, so it is dropped rather than guessed at. */
814
+ const ORIGIN_FILE_PREFIX = 'file:';
815
+ /**
816
+ * `<scope>\0<key>\n<value>\0` per entry, split on BYTES, or
817
+ * `<scope>\0<origin>\0<key>\n<value>\0` when the call passed `--show-origin`.
818
+ * A value may itself contain a newline, so the key ends at the FIRST one; a
819
+ * valueless key (`[section] key` with no `=`) has no newline at all.
820
+ */
821
+ function parseConfigList(out, withOrigin = false) {
822
+ const parts = splitOnNul(out);
823
+ const stride = withOrigin ? 3 : 2;
824
+ const entries = [];
825
+ for (let i = 0; i + stride - 1 < parts.length; i += stride) {
826
+ const record = parts[i + stride - 1];
827
+ const nl = record.indexOf(0x0a);
828
+ const key = decodeExact(nl === -1 ? record : record.subarray(0, nl));
829
+ const value = decodeExact(nl === -1 ? NO_BYTES : record.subarray(nl + 1));
830
+ const origin = withOrigin ? parts[i + 1].toString('utf-8') : '';
831
+ entries.push({
832
+ scope: parts[i].toString('utf-8'),
833
+ key: key.text,
834
+ keyExact: key.exact,
835
+ value: value.text,
836
+ valueExact: value.exact,
837
+ file: origin.startsWith(ORIGIN_FILE_PREFIX) ? origin.slice(ORIGIN_FILE_PREFIX.length) : undefined,
838
+ });
839
+ }
840
+ return entries;
841
+ }
842
+ /**
843
+ * How many submodule configs one call will read, and how far the enumeration
844
+ * follows submodules of submodules.
845
+ *
846
+ * Both are bounds on a tree the REPOSITORY owns — a checkout can declare as
847
+ * many submodules, nested as deeply, as whoever prepared it liked. Past
848
+ * either one the call is REFUSED rather than partly scanned: "we looked at
849
+ * some of your submodules" is a fail-open dressed as a limit. Every other
850
+ * bound in this pass throws for the same reason, which is the half that used
851
+ * to be missing — the depth guard and the directory-read failure both used
852
+ * to `return`, so a tree nested one level too deep, or a `.git/modules` we
853
+ * had no permission to read, silently became "this repository has no
854
+ * submodules".
855
+ *
856
+ * The count was 512, and that was a wall rather than a backstop: a
857
+ * superproject past it was refused forever, with a message naming nothing
858
+ * the user could change. 2048 is an order of magnitude past the largest real
859
+ * superproject, so only a tree built to reach it does. It is not raised
860
+ * further because every config read is one more `-c include.path=` argument
861
+ * on one command line, and a Windows command line stops at 32KB; and the
862
+ * message now names the two things that get the user moving again.
863
+ */
864
+ export const MAX_SUBMODULE_CONFIGS = 2048;
865
+ const MAX_SUBMODULE_DEPTH = 16;
866
+ /**
867
+ * The layout of the repository at `cwd`, or null when there is not one.
868
+ *
869
+ * The fast path is two `statSync`s: every caller inside Codeep passes the
870
+ * project ROOT, where `.git` is a directory sitting right there, so the usual
871
+ * case costs no child process at all — measured, it adds nothing to a
872
+ * hardened env in a plain repository at its root.
873
+ *
874
+ * Only the other shapes — a `cwd` below the root, a linked worktree, a
875
+ * submodule, where `.git` is a FILE or is not there at all — have to ask git,
876
+ * and that is one extra child, ~7ms, measured. Walking up
877
+ * for a `.git` ourselves would save it and would also be a second, worse copy
878
+ * of git's discovery rules (ceiling directories, `GIT_DIR`, worktree links),
879
+ * so git answers instead. The hot callers — the status line, /commit, the
880
+ * review path — all pass the root and never pay it; what does is an
881
+ * `execute_command` whose cwd is a subdirectory, once, on a command the user
882
+ * approved.
883
+ *
884
+ * `--show-toplevel` rides along in that same child rather than costing
885
+ * another. It fails outright in a bare repository ("this operation must be
886
+ * run in a work tree") and would take `--git-common-dir` down with it, so
887
+ * that case asks again without it.
888
+ */
889
+ function gitLayout(cwd, env) {
890
+ const here = join(cwd, '.git');
891
+ try {
892
+ // `isDirectory()` is not proof that there is a repository here, and
893
+ // taking it as proof made the layout name the wrong root. ANY directory
894
+ // can be called `.git` — one `mkdir sub/.git` inside a real checkout is
895
+ // the whole thing — and a half-built or half-deleted git directory has
896
+ // the same shape. The layout then said `cwd` was the top level, so
897
+ // indexGitlinkPaths listed the REAL repository's index (git finds it by
898
+ // walking up from `cwd`) with paths relative to a root that is not this
899
+ // one: every gitlink resolved to a directory that is not there, and
900
+ // gitDirOfWorktree dropped the lot. The submodule enumeration skipped
901
+ // silently while git, run from that same subdirectory, still ran the
902
+ // submodule's `filter.<d>.clean` on a plain `git status` — reproduced,
903
+ // git 2.54. `commonDir` was equally wrong, so `<commonDir>/modules` found
904
+ // nothing either.
905
+ //
906
+ // `HEAD` is git's own cheapest proof: validate_headref() is what setup.c
907
+ // calls before it will treat a directory as a git directory, and one more
908
+ // `statSync` keeps the fast path a fast path. This APPROXIMATES that
909
+ // function — git also wants `objects/` and `refs/`, and reads what HEAD
910
+ // contains — and it does not have to be exact, because being wrong in
911
+ // this direction costs correctness and not safety: everything not
912
+ // accepted here falls through to the `git rev-parse` below, which answers
913
+ // for every shape (a linked worktree, a submodule, a `cwd` below the
914
+ // root, and the fake `.git` above — where it names the real repository).
915
+ if (statSync(here).isDirectory() && statSync(join(here, 'HEAD')).isFile()) {
916
+ return { commonDir: here, topLevel: cwd };
917
+ }
918
+ }
919
+ catch {
920
+ // Not there, not readable, or no HEAD — fall through and let git answer.
921
+ }
922
+ const ask = (flags) => {
923
+ try {
924
+ return execFileSync('git', ['rev-parse', ...flags], {
925
+ cwd,
926
+ env,
927
+ timeout: SCAN_TIMEOUT_MS,
928
+ maxBuffer: SCAN_MAX_BUFFER,
929
+ encoding: 'utf-8',
930
+ stdio: ['ignore', 'pipe', 'pipe'],
931
+ })
932
+ .split('\n')
933
+ .map(line => line.trim());
934
+ }
935
+ catch {
936
+ return null;
937
+ }
938
+ };
939
+ // git prints the common dir relative to `cwd` when it can.
940
+ const both = ask(['--git-common-dir', '--show-toplevel']);
941
+ if (both?.[0]) {
942
+ return { commonDir: resolve(cwd, both[0]), topLevel: both[1] ? resolve(cwd, both[1]) : null };
943
+ }
944
+ const bare = ask(['--git-common-dir']);
945
+ if (bare?.[0])
946
+ return { commonDir: resolve(cwd, bare[0]), topLevel: null };
947
+ // git said "not a git repository", or never started. Either way the real
948
+ // call cannot run here either, so there is no submodule of ours to scan —
949
+ // the same reasoning listGitConfig() applies to its own null.
950
+ return null;
951
+ }
952
+ /**
953
+ * `submodule.<name>.<var>` — what git writes into the CONTAINING repository's
954
+ * own config for every submodule it has initialised (`git submodule add` and
955
+ * `git submodule update --init` both leave `submodule.<name>.url` and
956
+ * `.active` behind). That record is why the enumeration below needs no
957
+ * directory walk to find `<common>/modules/<name>`.
958
+ *
959
+ * The keys with no subsection — `submodule.recurse`, `submodule.active`,
960
+ * `submodule.fetchJobs` — need a second dot to match and do not.
961
+ */
962
+ const SUBMODULE_NAME_KEY = /^submodule\.(.+)\.[^.]+$/i;
963
+ /**
964
+ * The config files a submodule's git directory can carry, both of which git
965
+ * reads for a call inside that submodule.
966
+ *
967
+ * `config.worktree` was the hole: it sits in the git directory exactly as
968
+ * `config` does, git honours it as scope `worktree` the moment the repository
969
+ * sets `extensions.worktreeConfig`, and the pass below read only `config` —
970
+ * so a `filter.<d>.clean` written with `git config --worktree` inside a
971
+ * submodule survived the whole scan. It is the same file the isRepoScope()
972
+ * note is about, one level down.
973
+ *
974
+ * Read UNCONDITIONALLY rather than only when `extensions.worktreeConfig` is
975
+ * on. Deciding that would mean picking, out of one merged `--show-origin`
976
+ * listing, which repository's `extensions.worktreeConfig` belongs to which
977
+ * `config.worktree` — and getting the pairing wrong fails OPEN, which is the
978
+ * direction this pass never goes. Reading a `config.worktree` that git is
979
+ * currently ignoring can only over-refuse, and the `--unset` in the message
980
+ * still names the file that has the key in it.
981
+ */
982
+ const SUBMODULE_CONFIG_FILES = ['config', 'config.worktree'];
983
+ /** Every submodule name these entries record, de-duplicated. */
984
+ function submoduleNames(entries, cwd) {
985
+ const names = new Set();
986
+ for (const entry of entries) {
987
+ const m = entry.key.match(SUBMODULE_NAME_KEY);
988
+ if (!m)
989
+ continue;
990
+ // The name becomes a path component below, and a name whose bytes do not
991
+ // survive the round trip is one we cannot open — so the honest answers
992
+ // are the same two decodeExact() leaves everywhere else, and "skip it"
993
+ // is not one of them.
994
+ if (!entry.keyExact) {
995
+ throw refuse(cwd, 'its git config names a submodule whose name is not valid UTF-8, so Codeep cannot open that ' +
996
+ "submodule to check whether its own config names a program for git to run.");
997
+ }
998
+ names.add(m[1]);
999
+ }
1000
+ return [...names];
1001
+ }
1002
+ /**
1003
+ * `root` joined with `parts`, but only when the result stays under `root`.
1004
+ *
1005
+ * A submodule NAME comes out of a config file the repository wrote and a
1006
+ * submodule PATH out of its index, so `../../..` is something either of them
1007
+ * can say. git validates both itself — that is what CVE-2018-11235 was — and
1008
+ * this is the same check on our side: everything this pass opens has to be
1009
+ * under the directory it claims to be under.
1010
+ */
1011
+ function containedPath(root, part) {
1012
+ const full = resolve(root, part);
1013
+ const rel = relative(root, full);
1014
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) ? full : null;
1015
+ }
1016
+ /** `gitdir: <path>` — how a submodule's `.git` FILE names its real git dir. */
1017
+ const GITDIR_POINTER = /^gitdir:\s*(.*?)\s*$/;
1018
+ /**
1019
+ * The git directory of the checkout at `worktree`, resolved the way git
1020
+ * resolves it, or null when there is not one there.
1021
+ *
1022
+ * Both shapes are real and both escaped the `.git/modules` walk this
1023
+ * replaced. Reproduced with git 2.54, each with a `filter.<d>.clean` that
1024
+ * FIRED on the superproject's own `git status --porcelain`:
1025
+ *
1026
+ * - `.git` is a DIRECTORY. `git submodule add <url> <path>` over a path that
1027
+ * is already a checkout answers "Adding existing repo at '<path>' to the
1028
+ * index" and leaves the embedded git directory exactly where it is, so
1029
+ * `<common>/modules` is never even created.
1030
+ * - `.git` is a FILE naming a git directory elsewhere. Absorbed submodules
1031
+ * point at `<common>/modules/<name>`, and nothing stops a prepared
1032
+ * checkout from pointing one somewhere else.
1033
+ */
1034
+ function gitDirOfWorktree(worktree, cwd) {
1035
+ const dot = join(worktree, '.git');
1036
+ let stat;
1037
+ try {
1038
+ stat = statSync(dot);
1039
+ }
1040
+ catch (error) {
1041
+ const err = error;
1042
+ // Not checked out is an ordinary state, and `<common>/modules/<name>` is
1043
+ // then the only git dir there is. Anything else — EACCES, EIO, a symlink
1044
+ // loop — means git can read something we cannot, and answering "no
1045
+ // submodule here" to that is the fail-open this whole pass is removing.
1046
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR')
1047
+ return null;
1048
+ throw refuse(cwd, `the submodule at ${worktree} could not be read (${err.code ?? 'unknown error'}), so Codeep ` +
1049
+ 'cannot tell whether its git config names a program for git to run.');
1050
+ }
1051
+ if (stat.isDirectory())
1052
+ return dot;
1053
+ if (!stat.isFile())
1054
+ return null;
1055
+ let text;
1056
+ try {
1057
+ text = readFileSync(dot, 'utf-8');
1058
+ }
1059
+ catch (error) {
1060
+ const err = error;
1061
+ throw refuse(cwd, `the .git file of the submodule at ${worktree} could not be read (${err.code ?? 'unknown error'}), ` +
1062
+ 'so Codeep cannot tell whether its git config names a program for git to run.');
1063
+ }
1064
+ const pointer = text.split('\n')[0].match(GITDIR_POINTER);
1065
+ return pointer?.[1] ? resolve(worktree, pointer[1]) : null;
1066
+ }
1067
+ /** The index mode of a gitlink — the entry `git status` descends into. */
1068
+ const GITLINK_MODE = '160000';
1069
+ /**
1070
+ * Whether git will serve this directory as a working tree at all.
1071
+ *
1072
+ * Only ever asked once something else has already failed, so it is a child
1073
+ * process on the error path and nothing on the ordinary one. It answers the
1074
+ * narrow question "can any git command run here?", which is what separates a
1075
+ * repository Codeep should refuse from one where there is nothing to refuse
1076
+ * over because git itself has walked away.
1077
+ */
1078
+ function gitServesWorkTree(dir, env) {
1079
+ try {
1080
+ return (execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
1081
+ cwd: dir,
1082
+ env,
1083
+ timeout: SCAN_TIMEOUT_MS,
1084
+ maxBuffer: SCAN_MAX_BUFFER,
1085
+ encoding: 'utf-8',
1086
+ stdio: ['ignore', 'pipe', 'pipe'],
1087
+ }).trim() === 'true');
15
1088
  }
16
1089
  catch {
17
1090
  return false;
18
1091
  }
19
1092
  }
1093
+ /**
1094
+ * Every path this repository's INDEX records as a submodule.
1095
+ *
1096
+ * The index is the authoritative list, and the reason this is not driven by
1097
+ * `.gitmodules`: git descends into a path — and runs that checkout's
1098
+ * `filter.<d>.clean` on the automatic `git status` behind the status line —
1099
+ * because the INDEX says the path is a gitlink, not because `.gitmodules`
1100
+ * mentions it. Reproduced with git 2.54: a gitlink added with a plain
1101
+ * `git add`, with no `.gitmodules` entry and no `submodule.<name>.url`
1102
+ * anywhere, still fired the embedded checkout's clean filter. An enumeration
1103
+ * built on `.gitmodules` would have been a fix an attacker undoes by
1104
+ * deleting one line.
1105
+ *
1106
+ * `--abbrev=4` because the object names are 40 bytes each and nothing here
1107
+ * reads them: on a 100k-entry index that is 2.7MB of output instead of
1108
+ * 6.3MB, and this has to fit in INDEX_MAX_BUFFER. `-- :/` with `--full-name`
1109
+ * so the answer is the whole repository, root-relative, whatever `cwd` the
1110
+ * call came in with.
1111
+ *
1112
+ * `git ls-files` refreshes nothing, so it runs no filter and no fsmonitor
1113
+ * (verified against a marker, git 2.54) — safe to run before the hardening
1114
+ * is complete, exactly as `git config --list` is.
1115
+ */
1116
+ function indexGitlinkPaths(top, cwd, env) {
1117
+ let out;
1118
+ try {
1119
+ out = execFileSync('git', ['ls-files', '-s', '-z', '--full-name', '--abbrev=4', '--', ':/'], {
1120
+ cwd: top,
1121
+ env,
1122
+ timeout: SCAN_TIMEOUT_MS,
1123
+ // NOT the scan's buffer: this one is sized by how many files the
1124
+ // repository has, not by what an attacker padded. See INDEX_MAX_BUFFER.
1125
+ maxBuffer: INDEX_MAX_BUFFER,
1126
+ stdio: ['ignore', 'pipe', 'pipe'],
1127
+ });
1128
+ }
1129
+ catch (error) {
1130
+ // Two different failures arrive here with the same exit code, and only
1131
+ // one of them is ours to refuse over.
1132
+ //
1133
+ // git may be declining to serve this repository AT ALL — a
1134
+ // `core.repositoryformatversion` it does not understand, a `.git` that is
1135
+ // a directory but not a git directory, a config file it will not parse.
1136
+ // `git config --list` survives some of those (the version case only
1137
+ // warns, exit 0), and gitLayout's fast path is a pair of `statSync`s
1138
+ // rather than a question to git, so the scan gets this far and then meets
1139
+ // a git that will not read an index. The REAL call cannot run in such a
1140
+ // repository either, so there is no filter to neutralise and no submodule to find —
1141
+ // the same reasoning listGitConfig() applies to its own null. Refusing
1142
+ // instead turned every one of them into a hardening refusal about a
1143
+ // submodule scan the user has nothing to do with, and replaced the "git
1144
+ // failed to answer" that keeps the hook write gate in
1145
+ // utils/toolExecution.ts switched ON.
1146
+ //
1147
+ // So git is asked one more question, on the error path only, where it
1148
+ // costs nothing in the ordinary case: does it consider this a working
1149
+ // tree? A `no` means nothing runs here. A `yes` means git serves the
1150
+ // repository and still would not list its index — ENOBUFS on an oversized
1151
+ // one, a timeout — and that is the shape this has to fail closed on,
1152
+ // because the real call WILL run.
1153
+ if (!gitServesWorkTree(top, env))
1154
+ return [];
1155
+ const err = error;
1156
+ const detail = String(err.stderr ?? '').trim() || err.code || `exit ${err.status ?? '?'}`;
1157
+ // A read that ran out of room or out of time is OUR limit, and saying so
1158
+ // is the difference between a sentence a user can act on and one that
1159
+ // accuses their repository of something. The old text did the latter for
1160
+ // both: the reader was told Codeep could not tell what its directories
1161
+ // are, in the one wording this file uses for a hostile config, over a
1162
+ // monorepo whose only crime was having files in it. INDEX_MAX_BUFFER is
1163
+ // what makes this unreachable for a real repository; this is what it
1164
+ // reads like if it is ever reached anyway.
1165
+ //
1166
+ // It still stops the call. A gitlink the enumeration never saw is a
1167
+ // submodule whose own git config was never checked, and the real git call
1168
+ // WILL descend into it — which is the whole reason this pass exists. So
1169
+ // the honest answer is "Codeep could not check", not "there was nothing
1170
+ // to check", and it names a way forward that does not involve deleting
1171
+ // anything.
1172
+ const killed = err.killed === true || err.code === 'ETIMEDOUT';
1173
+ if (err.code === 'ENOBUFS' || killed) {
1174
+ throw refuse(cwd, `its index is too large for Codeep to list in one read (${detail}). Nothing in this repository ` +
1175
+ 'is wrong and there is no config key to remove — Codeep enumerates the index to find the ' +
1176
+ 'submodules git descends into, whose own git config can name a program for git to run, and it ' +
1177
+ 'will not run git here having read only part of that list. Run git in the directory you meant ' +
1178
+ 'instead (git -C <path> …).');
1179
+ }
1180
+ throw refuse(cwd, `its index could not be listed (${detail}), so Codeep cannot tell which of its directories are ` +
1181
+ 'submodules whose own git config may name a program for git to run.');
1182
+ }
1183
+ const paths = [];
1184
+ for (const record of splitOnNul(out)) {
1185
+ // `<mode> <object> <stage>\t<path>`. A path may contain anything but NUL,
1186
+ // including a tab, so it starts at the FIRST one.
1187
+ const tab = record.indexOf(0x09);
1188
+ if (tab === -1)
1189
+ continue;
1190
+ if (!record.subarray(0, tab).toString('utf-8').startsWith(`${GITLINK_MODE} `))
1191
+ continue;
1192
+ const path = decodeExact(record.subarray(tab + 1));
1193
+ if (!path.exact) {
1194
+ throw refuse(cwd, 'one of its submodule paths is not valid UTF-8, so Codeep cannot open that submodule to check ' +
1195
+ 'whether its git config names a program for git to run.');
1196
+ }
1197
+ paths.push(path.text);
1198
+ }
1199
+ return paths;
1200
+ }
1201
+ /**
1202
+ * Read a batch of config FILES in one git child, as `command`-scope entries
1203
+ * that carry the file each came from.
1204
+ *
1205
+ * `-c include.path=<file>` is what makes this ONE child for any number of
1206
+ * files: git reads every named file in the same process and reports their
1207
+ * entries under scope `command`. The alternative, a child per submodule,
1208
+ * measured 342ms on a 50-submodule fixture — on every status refresh.
1209
+ *
1210
+ * `--show-origin` is not decoration. Without it every entry in the merged
1211
+ * output looks like the superproject's, and a refusal then names the
1212
+ * superproject and prints a `git config --unset` that silently does nothing
1213
+ * there (see unsetCommand).
1214
+ *
1215
+ * `GIT_CONFIG_COUNT=0` on this child is load-bearing: our own overrides ride
1216
+ * in the environment as GIT_CONFIG_* pairs and git reports THOSE as scope
1217
+ * `command` too, so without it the pass would read back and re-neutralise
1218
+ * Codeep's own work.
1219
+ */
1220
+ function readConfigFiles(cwd, env, files) {
1221
+ // The `-c` pairs are git's own GLOBAL options and have to come before the
1222
+ // subcommand — after it, git 2.54 answers "unknown switch `c'". A missing
1223
+ // or unreadable include is silently skipped (verified, 2.54), so a
1224
+ // submodule directory that lost its config between the stat and here does
1225
+ // not fail the call.
1226
+ const args = [];
1227
+ for (const file of files)
1228
+ args.push('-c', `include.path=${file}`);
1229
+ args.push('config', '--list', '-z', '--show-scope', '--show-origin', '--includes');
1230
+ let out;
1231
+ try {
1232
+ out = execFileSync('git', args, {
1233
+ cwd,
1234
+ env: { ...env, GIT_CONFIG_COUNT: '0' },
1235
+ timeout: SCAN_TIMEOUT_MS,
1236
+ maxBuffer: SCAN_MAX_BUFFER,
1237
+ stdio: ['ignore', 'pipe', 'pipe'],
1238
+ });
1239
+ }
1240
+ catch (error) {
1241
+ // The scan reached the point of knowing there ARE submodule configs and
1242
+ // then could not read them, so this fails the call for the same reason
1243
+ // listGitConfig() does — a half-read config is how the repo-scope layer
1244
+ // silently switched itself off before.
1245
+ const err = error;
1246
+ const detail = String(err.stderr ?? '').trim() || err.code || `exit ${err.status ?? '?'}`;
1247
+ throw refuse(cwd, `the git config of its submodules could not be read (${detail}), so Codeep cannot tell whether ` +
1248
+ 'one of them names a program for git to run.');
1249
+ }
1250
+ return (parseConfigList(out, true)
1251
+ // `command` is what git reports for everything reached through our own
1252
+ // `-c include.path`. Any `local` / `global` / `system` entry here
1253
+ // belongs to the superproject or the user and is already handled by the
1254
+ // main scan, so taking only `command` keeps this pass to the submodules.
1255
+ .filter(entry => entry.scope === 'command')
1256
+ // The `include.path` pseudo-entries are Codeep's own argv coming back.
1257
+ .filter(entry => entry.key.toLowerCase() !== 'include.path'));
1258
+ }
1259
+ /**
1260
+ * The config of every submodule of the repository at `cwd`, as repo-supplied
1261
+ * entries that carry the file — and, where we know it, the working tree —
1262
+ * they came from.
1263
+ *
1264
+ * THE HOLE THIS CLOSES: a submodule's settings live in its own git config,
1265
+ * and `git config --list --show-scope` run at the superproject never prints
1266
+ * a single one of them. A `filter.<d>.clean` there still runs on the
1267
+ * automatic `git status` behind the status line — proven with git 2.54, the
1268
+ * superproject's `git status --porcelain` fired the submodule's clean filter
1269
+ * while the superproject's own config was spotless. The overrides themselves
1270
+ * do reach it: the same `git status` with `GIT_CONFIG_KEY_0=filter.<d>.clean`
1271
+ * left the trap cold.
1272
+ *
1273
+ * HOW THE SUBMODULES ARE FOUND, and why not by walking `.git/modules`, which
1274
+ * is what this shipped as. Three real layouts escaped that walk — each
1275
+ * reproduced against git 2.54 with a hostile `filter.<d>.clean` that FIRED on
1276
+ * the superproject's own `git status --porcelain` while the walk reported
1277
+ * nothing to scan:
1278
+ *
1279
+ * - an EMBEDDED git directory. `git submodule add` over a path that is
1280
+ * already a checkout leaves `<path>/.git` a real directory and never
1281
+ * creates `.git/modules` at all.
1282
+ * - a `.git` FILE repointed at a git directory outside `.git/modules`.
1283
+ * - a DECOY `config` at an intermediate level of a slashed submodule name:
1284
+ * one `touch .git/modules/vendor/config` hid `.git/modules/vendor/lib/
1285
+ * config` from a walk that stopped descending at the first `config` it
1286
+ * found. Writing one file is the whole attack.
1287
+ *
1288
+ * So the submodules are enumerated the way git itself records them, and the
1289
+ * two records between them cover all three:
1290
+ * - the INDEX's gitlinks (mode 160000) are the paths git descends into, and
1291
+ * each path's own `.git` gives its real git directory, wherever that is;
1292
+ * - `submodule.<name>.*` in the containing repository's config gives the
1293
+ * names git maps to `<common>/modules/<name>`, which is what covers a
1294
+ * submodule that is initialised but not checked out. A name is a direct
1295
+ * lookup, so nothing planted alongside it can hide it.
1296
+ * Nesting is followed by repeating the second source inside each config just
1297
+ * read — a submodule of a submodule is recorded in ITS parent's config, and
1298
+ * git puts it under `<that git dir>/modules/<name>`.
1299
+ *
1300
+ * A symlinked `.git/modules/<name>` is no longer a special case: the walk
1301
+ * used to SKIP one (`isDirectory()` is false for a symlink) and the
1302
+ * submodule behind it ran unscanned — reproduced. A name lookup opens it
1303
+ * exactly as git does, which is the right answer rather than a refusal.
1304
+ *
1305
+ * THE INDEX IS ALWAYS READ, and that is the fix this round makes. There used
1306
+ * to be a gate in front of it — run `git ls-files` only when the config named
1307
+ * a submodule, or `.git/modules` existed, or the working tree had a
1308
+ * `.gitmodules` — which skipped the very enumeration the rest of this
1309
+ * function is built on. A gitlink added with a plain `git add`, with no
1310
+ * `.gitmodules` and no `submodule.<name>.*` anywhere, leaves none of those
1311
+ * three signals, and git still descends into it and still runs that
1312
+ * checkout's `filter.<d>.clean` on the superproject's own `git status`
1313
+ * (reproduced, git 2.54). The gate was the one place that shape survived, so
1314
+ * it is gone: wherever there is a working tree, the index is enumerated.
1315
+ *
1316
+ * WHAT IS STILL NOT REACHED, named rather than implied: a NESTED submodule
1317
+ * that itself uses one of the two non-standard layouts above. Resolving
1318
+ * those needs the inner repository's index, and that is one child process
1319
+ * per submodule — the cost this pass exists to avoid. The shape git writes
1320
+ * by itself, `<common>/modules/<outer>/modules/<inner>`, is covered.
1321
+ *
1322
+ * WHAT IT COSTS, since this runs on every hardened git call. Measured here
1323
+ * against git 2.54, per hardenedGitEnv() call, at the repository root, as
1324
+ * the median of 15:
1325
+ * - No submodules, 307 files (this repository): 13.6ms, of which 7.0ms is
1326
+ * the `git config --list` that was already there and 6.6ms is the
1327
+ * `git ls-files` the gate used to skip. 10k files: 16.8ms.
1328
+ * - 100k files, still no submodules: 47.7ms. That is the honest worst case
1329
+ * and it is the index, not the submodules — `git ls-files -s` writes 3MB
1330
+ * there. `--abbrev=4` already trims it; `--format=%(objectmode) %(path)`
1331
+ * would save 2ms more and was left alone because it needs git ≥ 2.38 and
1332
+ * this is a hotfix.
1333
+ * - 1 submodule: 19.7ms. 10: 21.5ms. 50: 28.3ms. Near-flat in the submodule
1334
+ * count, because it is ONE `git ls-files` plus ONE `git config --list` for
1335
+ * ALL of them however many there are. The alternative, a child per
1336
+ * submodule, measured 342ms on the same 50-submodule fixture.
1337
+ *
1338
+ * So the check is memoised per call and not cached across calls: one
1339
+ * `git ls-files` per hardenedGitEnv(), which is why that function's own note
1340
+ * says to build the environment ONCE and hand the same object to every spawn
1341
+ * inside it. A cache that outlived the call would have to be invalidated on
1342
+ * the index changing, and the only cheap signal for that is `.git/index`'s
1343
+ * mtime — which is coarse enough to miss a write inside the same tick. Being
1344
+ * wrong there means running git against a gitlink this pass never opened,
1345
+ * which is the failure the gate above was just deleted for.
1346
+ */
1347
+ function listSubmoduleConfig(cwd, env, scanned, layout) {
1348
+ if (!layout)
1349
+ return [];
1350
+ const { commonDir, topLevel } = layout;
1351
+ const modulesRoot = join(commonDir, 'modules');
1352
+ const names = submoduleNames(scanned, cwd);
1353
+ /** Each config file's working tree, for the refusal text. */
1354
+ const worktrees = new Map();
1355
+ const roots = [];
1356
+ const addGitDir = (dir, worktree) => {
1357
+ roots.push(dir);
1358
+ if (worktree)
1359
+ for (const name of SUBMODULE_CONFIG_FILES)
1360
+ worktrees.set(join(dir, name), worktree);
1361
+ };
1362
+ for (const name of names) {
1363
+ const dir = containedPath(modulesRoot, name);
1364
+ if (!dir) {
1365
+ throw refuse(cwd, `its git config names a submodule ("${describeValue(name)}") whose git directory would sit ` +
1366
+ 'outside .git/modules. Codeep will not follow that.');
1367
+ }
1368
+ addGitDir(dir);
1369
+ }
1370
+ if (topLevel !== null) {
1371
+ for (const path of indexGitlinkPaths(topLevel, cwd, env)) {
1372
+ const worktree = containedPath(topLevel, path);
1373
+ if (!worktree) {
1374
+ throw refuse(cwd, `its index records a submodule at "${describeValue(path)}", which is outside the repository. ` +
1375
+ 'Codeep will not follow that.');
1376
+ }
1377
+ const dir = gitDirOfWorktree(worktree, cwd);
1378
+ if (dir)
1379
+ addGitDir(dir, worktree);
1380
+ }
1381
+ }
1382
+ const entries = [];
1383
+ /** Config files already read, by real path, so a symlink that aliases one
1384
+ * git directory onto another does not send the rounds below in a circle. */
1385
+ const read = new Set();
1386
+ /** Git directories this call has read a config out of, for the count cap. */
1387
+ let scannedDirs = 0;
1388
+ let frontier = roots;
1389
+ for (let depth = 0;; depth++) {
1390
+ const files = [];
1391
+ const dirs = [];
1392
+ for (const dir of frontier) {
1393
+ let any = false;
1394
+ for (const name of SUBMODULE_CONFIG_FILES) {
1395
+ const file = join(dir, name);
1396
+ // Not `existsSync`, which answers false for a file it has no
1397
+ // PERMISSION to look at — so one `chmod 000` on a submodule's git
1398
+ // directory used to turn its config into "there is no submodule here",
1399
+ // silently, while git went on reading it perfectly well.
1400
+ try {
1401
+ if (!statSync(file).isFile())
1402
+ continue;
1403
+ }
1404
+ catch (error) {
1405
+ const err = error;
1406
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR')
1407
+ continue;
1408
+ throw refuse(cwd, `the git config of its submodule at ${dir} could not be reached (${err.code ?? 'unknown error'}), ` +
1409
+ 'so Codeep cannot tell whether it names a program for git to run.');
1410
+ }
1411
+ let id;
1412
+ try {
1413
+ id = realpathSync(file);
1414
+ }
1415
+ catch (error) {
1416
+ const err = error;
1417
+ // Gone between the stat and here is a race, and git's own
1418
+ // `include.path` skips a missing file just as silently. Anything else
1419
+ // is a file git can open and we cannot, which has to fail the call.
1420
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR')
1421
+ continue;
1422
+ throw refuse(cwd, `the git config of its submodule at ${dir} could not be opened (${err.code ?? 'unknown error'}), ` +
1423
+ 'so Codeep cannot tell whether it names a program for git to run.');
1424
+ }
1425
+ if (read.has(id))
1426
+ continue;
1427
+ read.add(id);
1428
+ files.push(file);
1429
+ any = true;
1430
+ }
1431
+ // One entry per git DIRECTORY, whichever of its config files existed:
1432
+ // `dirs` only feeds the `<dir>/modules/<name>` pairing below, and a
1433
+ // directory listed twice would square that cross product.
1434
+ if (any)
1435
+ dirs.push(dir);
1436
+ }
1437
+ if (files.length === 0)
1438
+ break;
1439
+ if (depth >= MAX_SUBMODULE_DEPTH) {
1440
+ throw refuse(cwd, `its submodules are nested more than ${MAX_SUBMODULE_DEPTH} levels deep, and every level carries ` +
1441
+ 'its own git config that can name a program for git to run. Codeep will not run git here rather ' +
1442
+ 'than check only the levels it reached. Run git in the submodule you meant instead ' +
1443
+ '(git -C <path> …).');
1444
+ }
1445
+ // Counted in git DIRECTORIES, not in `read.size`: since `config.worktree`
1446
+ // joined the list a submodule can contribute two files, and counting
1447
+ // files would refuse a checkout for having half as many submodules as the
1448
+ // message claims.
1449
+ scannedDirs += dirs.length;
1450
+ if (scannedDirs > MAX_SUBMODULE_CONFIGS) {
1451
+ throw refuse(cwd, `it has more than ${MAX_SUBMODULE_CONFIGS} initialised submodules, and each one carries its own ` +
1452
+ 'git config that can name a program for git to run. Codeep will not run git here rather than ' +
1453
+ 'check only some of them. De-initialise the ones this checkout does not need ' +
1454
+ '(git submodule deinit <path>), or run git in the one you meant instead (git -C <path> …).');
1455
+ }
1456
+ const round = readConfigFiles(cwd, env, files);
1457
+ for (const entry of round) {
1458
+ entries.push({
1459
+ ...entry,
1460
+ scope: SUBMODULE_SCOPE,
1461
+ worktree: entry.file ? worktrees.get(entry.file) : undefined,
1462
+ });
1463
+ }
1464
+ // A submodule's own submodules are recorded in the config just read, and
1465
+ // git puts their git directories under `<that git dir>/modules/<name>`.
1466
+ // Which parent goes with which name is not in the merged output, so every
1467
+ // pairing is offered and the filesystem answers — a stat each, no child.
1468
+ //
1469
+ // The pairing is a cross product, so it is capped like everything else
1470
+ // here and it is capped BEFORE the work rather than by truncating the
1471
+ // list afterwards: dropping candidates quietly would be one more bound
1472
+ // that fails open, and a superproject that could produce this many is
1473
+ // one the count cap is about to refuse anyway.
1474
+ const nested = submoduleNames(round, cwd);
1475
+ if (dirs.length * nested.length > MAX_SUBMODULE_CONFIGS) {
1476
+ throw refuse(cwd, `its submodules declare more than ${MAX_SUBMODULE_CONFIGS} submodules of their own, and each one ` +
1477
+ 'carries a git config that can name a program for git to run. Codeep will not run git here ' +
1478
+ 'rather than check only some of them. De-initialise the ones this checkout does not need ' +
1479
+ '(git submodule deinit <path>), or run git in the one you meant instead (git -C <path> …).');
1480
+ }
1481
+ frontier = [];
1482
+ for (const dir of dirs) {
1483
+ for (const name of nested) {
1484
+ const child = containedPath(join(dir, 'modules'), name);
1485
+ if (child)
1486
+ frontier.push(child);
1487
+ }
1488
+ }
1489
+ }
1490
+ return entries;
1491
+ }
1492
+ /**
1493
+ * The override pairs that neutralise every command-executing key the
1494
+ * REPOSITORY set, leaving the same keys alone when they came from the user's
1495
+ * global or system config. Throws when the repository named something no
1496
+ * override can reach — see GitHardeningError.
1497
+ */
1498
+ function repoSuppliedOverrides(cwd, env) {
1499
+ const scanned = listGitConfig(cwd, env);
1500
+ if (!scanned)
1501
+ return [];
1502
+ // The superproject's own config plus its submodules'. The two lists are
1503
+ // joined here rather than scanned together because git cannot print them
1504
+ // together: see listSubmoduleConfig() for what `--show-scope` at the
1505
+ // superproject leaves out and what that costs.
1506
+ //
1507
+ // `gitLayout` is called straight through rather than behind a memoised
1508
+ // getter. The getter existed for the content-filter rule, which used to ask
1509
+ // for the layout again on the refusal path; that rule no longer asks
1510
+ // anything, and this call site resolved it eagerly anyway, so the memo
1511
+ // never saved a child process.
1512
+ const entries = [...scanned, ...listSubmoduleConfig(cwd, env, scanned, gitLayout(cwd, env))];
1513
+ const overrides = [];
1514
+ for (const entry of entries) {
1515
+ if (!isRepoScope(entry.scope))
1516
+ continue;
1517
+ const rule = REPO_EXECUTING_RULES.find(r => r.match.test(entry.key));
1518
+ if (!rule)
1519
+ continue;
1520
+ // The key matched a rule, so git runs what it names. If its bytes cannot
1521
+ // be spelled back into the environment there is no override to emit, and
1522
+ // the only answer left is to not run git here.
1523
+ if (!entry.keyExact) {
1524
+ throw refuse(cwd, `${describeConfig(entry)} sets "${entry.key}", whose name is not valid UTF-8. Git runs the ` +
1525
+ 'program that key names, and no environment override can spell the key back exactly. Remove it ' +
1526
+ `from ${entry.file ?? '.git/config'}.`);
1527
+ }
1528
+ if ('refuse' in rule) {
1529
+ // null means the entry is safe as it stands — the well-known content
1530
+ // filters, which have to keep running for git-lfs, git-crypt and
1531
+ // nbstripout repositories to work at all.
1532
+ const why = rule.refuse(entry, entry.key.match(rule.match), env);
1533
+ if (why)
1534
+ throw refuse(cwd, why);
1535
+ continue;
1536
+ }
1537
+ overrides.push(...rule.neutralise(entry, entry.key.match(rule.match)));
1538
+ }
1539
+ // Several gpg keys collapse to the same "stop signing" pairs; last one wins
1540
+ // anyway, so drop the repeats rather than pad the environment with them.
1541
+ const seen = new Set();
1542
+ const deduped = overrides.filter(([key]) => {
1543
+ const dup = seen.has(key);
1544
+ seen.add(key);
1545
+ return !dup;
1546
+ });
1547
+ // Credential helpers are a list built from every `credential.helper` and
1548
+ // `credential.<url>.helper` in config order, and there is no way to remove
1549
+ // one entry — an empty value RESETS the whole list. So when the repository
1550
+ // contributed one, reset and re-add the others, each under ITS OWN key so a
1551
+ // URL-scoped helper does not become a global one.
1552
+ //
1553
+ // This block is appended AFTER the de-duplication on purpose: every entry
1554
+ // here shares the key `credential.helper`, and de-duplicating by key would
1555
+ // keep the reset and throw away every helper being restored — leaving the
1556
+ // user with no credential helper at all.
1557
+ const helpers = entries.filter(e => CREDENTIAL_HELPER_KEY.test(e.key));
1558
+ if (helpers.some(e => isRepoScope(e.scope))) {
1559
+ deduped.push(['credential.helper', '']);
1560
+ for (const helper of helpers) {
1561
+ if (isRepoScope(helper.scope))
1562
+ continue;
1563
+ // Re-declaring the user's own helper needs its key and its value back
1564
+ // byte for byte. Anything less would quietly point git at a different
1565
+ // program than the one it had, so refuse instead of guessing.
1566
+ if (!helper.keyExact || !helper.valueExact) {
1567
+ throw refuse(cwd, `it sets its own credential.helper, and switching that off means re-declaring the helpers you ` +
1568
+ `configured — but "${helper.key}" is not valid UTF-8 and cannot be re-declared exactly.`);
1569
+ }
1570
+ deduped.push([helper.key, helper.value]);
1571
+ }
1572
+ }
1573
+ return deduped;
1574
+ }
1575
+ /**
1576
+ * The environment for a git child process, with every command-executing config
1577
+ * key neutralised. Pass it to EVERY git spawn — including the read-only ones:
1578
+ * `git status` is the call that runs `core.fsmonitor` and a `filter.<d>.clean`.
1579
+ *
1580
+ * It costs one `git config --list` plus one `git ls-files` per call —
1581
+ * measured 13.6ms here in a plain repository at its root, 19.7ms in one with
1582
+ * a submodule, 28.3ms with fifty of them and 47.7ms in a 100k-file checkout
1583
+ * with none (see listSubmoduleConfig for where each part goes, and for why
1584
+ * the index is read even in a repository that declares no submodules) — so build
1585
+ * it ONCE per function and hand the same object to every spawn inside. There is
1586
+ * deliberately no cache across calls: the scan's whole job is to notice what
1587
+ * the repository's config says RIGHT NOW, and a hostile `.git/config` written
1588
+ * after a cache warmed would be the one it failed to neutralise. Nothing needs
1589
+ * one either — the only repeated caller, the status-line branch in
1590
+ * renderer/main.ts, already caches its own result and re-reads only when the
1591
+ * project moved or an agent run finished.
1592
+ *
1593
+ * Environment variables are the USER's, not the repository's, so this removes
1594
+ * exactly one and leaves the rest:
1595
+ *
1596
+ * - `GIT_CONFIG_PARAMETERS` is deleted. Git reads it AFTER the
1597
+ * `GIT_CONFIG_COUNT` pairs and it wins, which silently disables this whole
1598
+ * function (verified). Nothing sets it but git itself, for its own children.
1599
+ * - `GIT_EXTERNAL_DIFF`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`, `GIT_PROXY_COMMAND`
1600
+ * name programs, but ones the user exported for their own git. Codeep's diff
1601
+ * reads pass `--no-ext-diff`, which beats `GIT_EXTERNAL_DIFF` anyway.
1602
+ * - `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR` are kept
1603
+ * because a hook exports them: the pre-commit hook Codeep installs runs
1604
+ * `codeep review`, and `git diff --cached` there must read the hook's
1605
+ * TEMPORARY index to see what is really being committed.
1606
+ * - `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` / `GIT_ALTERNATE_OBJECT_DIRECTORIES`
1607
+ * are kept: the scan above runs under this same environment, so it sees
1608
+ * whatever they make git see.
1609
+ *
1610
+ * Anyone who can set environment variables on this process already owns it.
1611
+ *
1612
+ * THROWS `GitHardeningError` rather than return a half-built environment when
1613
+ * the config scan cannot complete, or when the repository named a program no
1614
+ * override can switch off. The scan used to swallow every error and fall back
1615
+ * to the always-on pairs alone, so a repository that padded its `.git/config`
1616
+ * past the read buffer turned the entire repo-scope layer off in silence and
1617
+ * ran its `filter.<d>.clean` on the next `git status`. Every caller in this
1618
+ * file catches it and degrades: the status line loses its branch, `/commit`,
1619
+ * `@git` and the review path show `error.message`, which is written for the
1620
+ * user. A NEW caller has to do the same, or the refusal reaches them as a
1621
+ * crash — and a caller inside a promise executor that does not catch it never
1622
+ * settles at all.
1623
+ */
1624
+ export function hardenedGitEnv(options = {}) {
1625
+ const { cwd = process.cwd(), noHooks = false, base = process.env } = options;
1626
+ const env = { ...base };
1627
+ delete env.GIT_CONFIG_PARAMETERS;
1628
+ // A git that decides to ask for a password would block the UI forever —
1629
+ // there is no terminal behind these pipes for the user to answer on.
1630
+ env.GIT_TERMINAL_PROMPT = '0';
1631
+ // `pager.<cmd>` overrides core.pager, and GIT_PAGER overrides both.
1632
+ env.GIT_PAGER = 'cat';
1633
+ // Merge rather than clobber: the caller (or a wrapper around Codeep) may
1634
+ // already declare GIT_CONFIG_* pairs, and dropping them would silently
1635
+ // change how their git behaves. Ours go AFTER theirs because the last entry
1636
+ // wins, so nothing in the environment can re-enable an executing key. A
1637
+ // count git itself would reject (anything but digits) is treated as no
1638
+ // entries: git fails outright on a bogus count, so there is nothing worth
1639
+ // preserving.
1640
+ const declared = env.GIT_CONFIG_COUNT ?? '';
1641
+ let count = /^\d+$/.test(declared) ? Number(declared) : 0;
1642
+ const append = (key, value) => {
1643
+ env[`GIT_CONFIG_KEY_${count}`] = key;
1644
+ env[`GIT_CONFIG_VALUE_${count}`] = value;
1645
+ count++;
1646
+ };
1647
+ for (const [key, value] of GIT_EXECUTING_CONFIG)
1648
+ append(key, value);
1649
+ if (noHooks)
1650
+ append('core.hooksPath', NO_HOOKS_PATH);
1651
+ // Commit what we have before scanning, so the scan reads the config through
1652
+ // the same environment the real call will use.
1653
+ env.GIT_CONFIG_COUNT = String(count);
1654
+ for (const [key, value] of repoSuppliedOverrides(cwd, env))
1655
+ append(key, value);
1656
+ env.GIT_CONFIG_COUNT = String(count);
1657
+ return env;
1658
+ }
1659
+ function checkGitRepository(cwd) {
1660
+ const gitDir = join(cwd, '.git');
1661
+ if (existsSync(gitDir))
1662
+ return { isRepo: true };
1663
+ try {
1664
+ // Only reached when `cwd` sits BELOW the repository root: the existsSync
1665
+ // above answers the root case without running git, and Codeep's
1666
+ // projectPath is the root.
1667
+ execSync('git rev-parse --git-dir', {
1668
+ cwd,
1669
+ stdio: ['ignore', 'ignore', 'ignore'],
1670
+ env: hardenedGitEnv({ cwd, noHooks: true }),
1671
+ });
1672
+ return { isRepo: true };
1673
+ }
1674
+ catch (error) {
1675
+ // A refusal means we will not run git here, so the answer is still "no"
1676
+ // — the difference is that the caller can say WHY. Anything else (no git
1677
+ // on PATH, genuinely not a repository) keeps the bare no.
1678
+ return error instanceof GitHardeningError
1679
+ ? { isRepo: false, refusal: error.message }
1680
+ : { isRepo: false };
1681
+ }
1682
+ }
1683
+ /** A repository whose git Codeep is willing to run. Refusals answer `false`;
1684
+ * callers that can show a reason use the functions below, which carry it. */
1685
+ export function isGitRepository(cwd = process.cwd()) {
1686
+ return checkGitRepository(cwd).isRepo;
1687
+ }
1688
+ /** The message a caller prints when checkGitRepository() said no. */
1689
+ function notARepo(check) {
1690
+ return check.refusal ?? 'Not a git repository';
1691
+ }
20
1692
  /**
21
1693
  * Get current git status
22
1694
  */
23
1695
  export function getGitStatus(cwd = process.cwd()) {
24
- if (!isGitRepository(cwd)) {
25
- return { isRepo: false };
1696
+ const repo = checkGitRepository(cwd);
1697
+ if (!repo.isRepo) {
1698
+ // A refusal answers `isRepo: true` on purpose. `false` means "nothing to
1699
+ // show here", and the status line draws nothing; a refusal means there is
1700
+ // something to show and it is a sentence the user can act on. We only
1701
+ // reach this branch below the repository root, where git was asked and
1702
+ // said no, so "this is version-controlled" is the safer of the two
1703
+ // guesses — and the wrong guess still shows the reason rather than a
1704
+ // blank.
1705
+ // `refusal` as well as `error`: the TUI's warning reads the narrow field
1706
+ // (see gitRefusalNotice in renderer/main.ts) and callers that only know
1707
+ // about `error` keep the same text.
1708
+ return repo.refusal ? { isRepo: true, error: repo.refusal, refusal: repo.refusal } : { isRepo: false };
26
1709
  }
27
1710
  try {
28
- // Get current branch
29
- const branch = execSync('git rev-parse --abbrev-ref HEAD', {
30
- cwd,
31
- encoding: 'utf-8'
32
- }).trim();
1711
+ // One environment for all three reads: building it costs a `git config
1712
+ // --list`, and these commands are the status line's, so they run often.
1713
+ // Codeep asks for them by itself, so the repository's hooks stay out.
1714
+ // Inside the try because it throws when the config cannot be scanned: the
1715
+ // status line has to degrade to "no branch", never to a crash.
1716
+ const env = hardenedGitEnv({ cwd, noHooks: true });
1717
+ // Get current branch. stderr is piped rather than inherited — execSync's
1718
+ // default sends it to ours, and a repository with no commit yet answers
1719
+ // `fatal: ambiguous argument 'HEAD'` into the middle of the TUI.
1720
+ //
1721
+ // Caught HERE rather than by the outer catch, which is what it used to
1722
+ // do. A brand-new `git init` has no commit for HEAD to name, so this line
1723
+ // throws and took the whole function down with it: `hasChanges` came back
1724
+ // undefined, and autoCommitAgentChanges() reads exactly that field — so
1725
+ // the first agent run in a new project reported "No changes detected by
1726
+ // git" over a working tree full of the files it had just written, and the
1727
+ // auto-commit the user had switched on never happened once until they
1728
+ // committed something by hand. A repository with no commits is an
1729
+ // ordinary repository; only the branch is unknown in it. The reason still
1730
+ // lands in `error` (never in `refusal` — it is not a hardening refusal),
1731
+ // which is the field GitStatus declares for "why there is no branch".
1732
+ let branch;
1733
+ let branchError;
1734
+ try {
1735
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
1736
+ cwd,
1737
+ encoding: 'utf-8',
1738
+ env,
1739
+ stdio: ['ignore', 'pipe', 'pipe'],
1740
+ }).trim();
1741
+ }
1742
+ catch (error) {
1743
+ branchError = error instanceof Error ? error.message : 'Unknown error';
1744
+ }
33
1745
  // Check for changes
34
1746
  const status = execSync('git status --porcelain', {
35
1747
  cwd,
36
- encoding: 'utf-8'
1748
+ encoding: 'utf-8',
1749
+ env,
1750
+ stdio: ['ignore', 'pipe', 'pipe'],
37
1751
  });
38
1752
  const hasChanges = status.trim().length > 0;
39
1753
  // Check ahead/behind
@@ -42,7 +1756,13 @@ export function getGitStatus(cwd = process.cwd()) {
42
1756
  try {
43
1757
  const counts = execSync('git rev-list --left-right --count @{u}...HEAD', {
44
1758
  cwd,
45
- encoding: 'utf-8'
1759
+ encoding: 'utf-8',
1760
+ env,
1761
+ // The common failure, not an exceptional one: a branch with no
1762
+ // upstream fails here on every status refresh, and the inherited
1763
+ // stderr put `fatal: no upstream configured for branch 'x'` straight
1764
+ // into the TUI once a second.
1765
+ stdio: ['ignore', 'pipe', 'pipe'],
46
1766
  }).trim();
47
1767
  const [behindStr, aheadStr] = counts.split('\t');
48
1768
  behind = parseInt(behindStr) || 0;
@@ -57,12 +1777,27 @@ export function getGitStatus(cwd = process.cwd()) {
57
1777
  hasChanges,
58
1778
  ahead,
59
1779
  behind,
1780
+ error: branchError,
60
1781
  };
61
1782
  }
62
1783
  catch (error) {
1784
+ // No `as GitStatus` any more: the cast was the whole reason this value
1785
+ // could claim a field the type did not have, so nothing ever read it back
1786
+ // and a refusal looked like a branch that vanished. `error` is declared on
1787
+ // GitStatus now, and the compiler checks this object against it.
1788
+ //
1789
+ // `refusal` is filled ONLY for a GitHardeningError, which is what makes
1790
+ // it safe for the TUI to print as an instruction. Everything else here is
1791
+ // an ordinary git failure, and telling that user to remove a config key
1792
+ // they do not have is worse than saying nothing. The brand-new `git init`
1793
+ // no longer arrives here at all — its `fatal: ambiguous argument 'HEAD'`
1794
+ // is caught at the branch read above so the rest of the status survives —
1795
+ // but it still fills `error` from there, on the same terms.
1796
+ const message = error instanceof Error ? error.message : 'Unknown error';
63
1797
  return {
64
1798
  isRepo: true,
65
- error: error instanceof Error ? error.message : 'Unknown error',
1799
+ error: message,
1800
+ refusal: error instanceof GitHardeningError ? message : undefined,
66
1801
  };
67
1802
  }
68
1803
  }
@@ -70,19 +1805,36 @@ export function getGitStatus(cwd = process.cwd()) {
70
1805
  * Get git diff (staged or unstaged)
71
1806
  */
72
1807
  export function getGitDiff(staged = false, cwd = process.cwd()) {
73
- if (!isGitRepository(cwd)) {
1808
+ const repo = checkGitRepository(cwd);
1809
+ if (!repo.isRepo) {
74
1810
  return {
75
1811
  success: false,
76
1812
  diff: '',
77
- error: 'Not a git repository',
1813
+ error: notARepo(repo),
78
1814
  };
79
1815
  }
80
1816
  try {
81
- const command = staged ? 'git diff --cached' : 'git diff';
1817
+ // --no-ext-diff disallows external diff drivers (`diff.external` and any
1818
+ // `diff.<driver>.command` a `.gitattributes` names) and --no-textconv the
1819
+ // `diff.<driver>.textconv` renderers, whatever scope they came from. The
1820
+ // repo-scope scan in hardenedGitEnv() covers the same keys, so these
1821
+ // flags are the belt to its braces: they still hold if the scan could not
1822
+ // run. The cost is that a user's GLOBAL textconv (`*.pdf` through
1823
+ // pdftotext) does not apply to the diffs Codeep reads — which is what we
1824
+ // want anyway, since these go to a model, and the raw patch is the
1825
+ // faithful one.
1826
+ const command = staged
1827
+ ? 'git diff --no-ext-diff --no-textconv --cached'
1828
+ : 'git diff --no-ext-diff --no-textconv';
82
1829
  const diff = execSync(command, {
83
1830
  cwd,
84
1831
  encoding: 'utf-8',
85
1832
  maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
1833
+ // stderr piped rather than inherited (execSync's default): git's own
1834
+ // `fatal:` belongs in the `error` this function returns, not in the
1835
+ // middle of the TUI.
1836
+ stdio: ['ignore', 'pipe', 'pipe'],
1837
+ env: hardenedGitEnv({ cwd, noHooks: true }),
86
1838
  });
87
1839
  if (!diff.trim()) {
88
1840
  return {
@@ -105,30 +1857,48 @@ export function getGitDiff(staged = false, cwd = process.cwd()) {
105
1857
  }
106
1858
  }
107
1859
  /**
108
- * Get list of changed files
1860
+ * Get list of changed files, with the reason when there are none.
109
1861
  */
110
- export function getChangedFiles(cwd = process.cwd()) {
111
- if (!isGitRepository(cwd)) {
112
- return [];
1862
+ export function getChangedFilesResult(cwd = process.cwd()) {
1863
+ const repo = checkGitRepository(cwd);
1864
+ if (!repo.isRepo) {
1865
+ return { files: [], error: notARepo(repo) };
113
1866
  }
114
1867
  try {
115
1868
  const output = execSync('git status --porcelain', {
116
1869
  cwd,
117
- encoding: 'utf-8'
1870
+ encoding: 'utf-8',
1871
+ // stderr piped, not inherited: execSync sends it to ours by default,
1872
+ // and git's own `fatal:` lines belong in the message below rather than
1873
+ // in the middle of the TUI.
1874
+ stdio: ['ignore', 'pipe', 'pipe'],
1875
+ env: hardenedGitEnv({ cwd, noHooks: true }),
118
1876
  });
119
- return output
120
- .trim()
1877
+ // Split BEFORE trimming. Porcelain status codes are two columns wide and
1878
+ // an unstaged modification leaves the first one blank (" M a.txt"), so
1879
+ // trimming the whole output ate two characters off the first entry
1880
+ // whenever it was one of those — " M a.txt" came back as ".txt".
1881
+ const files = output
121
1882
  .split('\n')
122
1883
  .filter(line => line.trim())
123
1884
  .map(line => {
124
1885
  // Format: "XY filename" where XY are status codes
125
1886
  return line.substring(3).trim();
126
1887
  });
1888
+ return { files };
127
1889
  }
128
- catch {
129
- return [];
1890
+ catch (error) {
1891
+ return { files: [], error: error instanceof Error ? error.message : 'Failed to list changes' };
130
1892
  }
131
1893
  }
1894
+ /**
1895
+ * Get list of changed files. Empty on any failure — see
1896
+ * getChangedFilesResult() when the difference between "nothing changed" and
1897
+ * "git was refused" matters.
1898
+ */
1899
+ export function getChangedFiles(cwd = process.cwd()) {
1900
+ return getChangedFilesResult(cwd).files;
1901
+ }
132
1902
  /**
133
1903
  * Generate commit message suggestion based on diff
134
1904
  */
@@ -166,17 +1936,30 @@ export function suggestCommitMessage(diff) {
166
1936
  * Create a commit with the given message
167
1937
  */
168
1938
  export function createCommit(message, cwd = process.cwd()) {
169
- if (!isGitRepository(cwd)) {
1939
+ const repo = checkGitRepository(cwd);
1940
+ if (!repo.isRepo) {
170
1941
  return {
171
1942
  success: false,
172
- error: 'Not a git repository',
1943
+ error: notARepo(repo),
173
1944
  };
174
1945
  }
175
1946
  try {
176
- // Check if there are staged changes
1947
+ // noHooks stays off: a commit is something the user asked for (/commit,
1948
+ // /git-commit, the agent auto-commit they enabled), so the repository's
1949
+ // pre-commit, commit-msg and post-commit hooks run exactly as they would
1950
+ // in their terminal — lint-staged, their signing hook, Codeep's own review
1951
+ // hook. Only the programs a repo can name in its CONFIG are neutralised.
1952
+ // Inside the try so a refusal comes back as `error`, which is what
1953
+ // /commit prints, instead of escaping as an exception.
1954
+ const env = hardenedGitEnv({ cwd });
1955
+ // Check if there are staged changes. stderr piped rather than inherited:
1956
+ // execSync sends it to ours by default, so a `fatal:` from git landed in
1957
+ // the middle of the TUI instead of in the `error` this function returns.
177
1958
  const staged = execSync('git diff --cached --name-only', {
178
1959
  cwd,
179
- encoding: 'utf-8'
1960
+ encoding: 'utf-8',
1961
+ stdio: ['ignore', 'pipe', 'pipe'],
1962
+ env,
180
1963
  }).trim();
181
1964
  if (!staged) {
182
1965
  return {
@@ -189,6 +1972,7 @@ export function createCommit(message, cwd = process.cwd()) {
189
1972
  cwd,
190
1973
  encoding: 'utf-8',
191
1974
  stdio: 'pipe',
1975
+ env,
192
1976
  });
193
1977
  if (result.status !== 0) {
194
1978
  throw new Error(result.stderr || 'Commit failed');
@@ -196,7 +1980,9 @@ export function createCommit(message, cwd = process.cwd()) {
196
1980
  // Get commit hash
197
1981
  const hash = execSync('git rev-parse --short HEAD', {
198
1982
  cwd,
199
- encoding: 'utf-8'
1983
+ encoding: 'utf-8',
1984
+ stdio: ['ignore', 'pipe', 'pipe'],
1985
+ env,
200
1986
  }).trim();
201
1987
  return {
202
1988
  success: true,
@@ -211,20 +1997,39 @@ export function createCommit(message, cwd = process.cwd()) {
211
1997
  }
212
1998
  }
213
1999
  /**
214
- * Stage all changes
2000
+ * Stage all changes, with the reason when it did not happen.
2001
+ *
2002
+ * The reason matters most in a repository with a REQUIRED content filter —
2003
+ * git-crypt, git-lfs, any repo-local `filter.<d>.required = true`. The
2004
+ * repo-scope layer empties that driver's `clean` command and deliberately
2005
+ * leaves `required` alone, so git aborts with `fatal: <file>: clean filter
2006
+ * '<d>' failed` and exit 128 rather than writing the unfiltered content. That
2007
+ * is the intended outcome (see the filter rule above: the alternative was
2008
+ * plaintext secrets in the object database), and it is only useful if the
2009
+ * user gets to read it — `stdio: 'ignore'` here used to throw the sentence
2010
+ * away and leave them with "Failed to stage changes".
215
2011
  */
216
- export function stageAll(cwd = process.cwd()) {
217
- if (!isGitRepository(cwd)) {
218
- return false;
2012
+ export function stageAllResult(cwd = process.cwd()) {
2013
+ const repo = checkGitRepository(cwd);
2014
+ if (!repo.isRepo) {
2015
+ return { success: false, error: notARepo(repo) };
219
2016
  }
220
2017
  try {
221
- execSync('git add -A', { cwd, stdio: 'ignore' });
222
- return true;
2018
+ execSync('git add -A', { cwd, stdio: ['ignore', 'ignore', 'pipe'], env: hardenedGitEnv({ cwd }) });
2019
+ return { success: true };
223
2020
  }
224
- catch {
225
- return false;
2021
+ catch (error) {
2022
+ // execSync folds the piped stderr into the thrown error's message, which
2023
+ // is how the `fatal: … clean filter … failed` line gets here at all.
2024
+ return { success: false, error: error instanceof Error ? error.message : 'Failed to stage changes' };
226
2025
  }
227
2026
  }
2027
+ /**
2028
+ * Stage all changes. See stageAllResult() when the reason matters.
2029
+ */
2030
+ export function stageAll(cwd = process.cwd()) {
2031
+ return stageAllResult(cwd).success;
2032
+ }
228
2033
  /**
229
2034
  * Format git diff for display
230
2035
  */
@@ -241,16 +2046,28 @@ export function formatDiffForDisplay(diff, maxLines = 50) {
241
2046
  * Create a new branch
242
2047
  */
243
2048
  export function createBranch(branchName, cwd = process.cwd()) {
244
- if (!isGitRepository(cwd)) {
245
- return { success: false, error: 'Not a git repository' };
2049
+ const repo = checkGitRepository(cwd);
2050
+ if (!repo.isRepo) {
2051
+ return { success: false, error: notARepo(repo) };
246
2052
  }
247
2053
  try {
2054
+ // As in createCommit: the checkout is the user's, so its post-checkout
2055
+ // hook runs. See HardenedGitEnvOptions.noHooks. Inside the try so a
2056
+ // refusal comes back as `error` rather than escaping as an exception.
2057
+ const env = hardenedGitEnv({ cwd });
248
2058
  // Check if branch already exists
249
- const branches = execSync('git branch --list', { cwd, encoding: 'utf-8' });
2059
+ // stderr piped rather than inherited (execSync's default), so git's own
2060
+ // `fatal:` reaches the `error` below instead of the middle of the TUI.
2061
+ const branches = execSync('git branch --list', {
2062
+ cwd,
2063
+ encoding: 'utf-8',
2064
+ stdio: ['ignore', 'pipe', 'pipe'],
2065
+ env,
2066
+ });
250
2067
  if (branches.includes(branchName)) {
251
2068
  return { success: false, error: `Branch '${branchName}' already exists` };
252
2069
  }
253
- execSync(`git checkout -b ${branchName}`, { cwd, stdio: 'ignore' });
2070
+ execSync(`git checkout -b ${branchName}`, { cwd, stdio: ['ignore', 'ignore', 'pipe'], env });
254
2071
  return { success: true };
255
2072
  }
256
2073
  catch (error) {
@@ -264,11 +2081,12 @@ export function createBranch(branchName, cwd = process.cwd()) {
264
2081
  * Switch to a branch
265
2082
  */
266
2083
  export function switchBranch(branchName, cwd = process.cwd()) {
267
- if (!isGitRepository(cwd)) {
268
- return { success: false, error: 'Not a git repository' };
2084
+ const repo = checkGitRepository(cwd);
2085
+ if (!repo.isRepo) {
2086
+ return { success: false, error: notARepo(repo) };
269
2087
  }
270
2088
  try {
271
- execSync(`git checkout ${branchName}`, { cwd, stdio: 'ignore' });
2089
+ execSync(`git checkout ${branchName}`, { cwd, stdio: ['ignore', 'ignore', 'pipe'], env: hardenedGitEnv({ cwd }) });
272
2090
  return { success: true };
273
2091
  }
274
2092
  catch (error) {
@@ -328,22 +2146,60 @@ export function generateCommitMessage(prompt, actions) {
328
2146
  * Auto-commit agent changes
329
2147
  */
330
2148
  export function autoCommitAgentChanges(prompt, actions, cwd = process.cwd()) {
331
- if (!isGitRepository(cwd)) {
332
- return { success: false, error: 'Not a git repository' };
2149
+ const repo = checkGitRepository(cwd);
2150
+ if (!repo.isRepo) {
2151
+ return { success: false, error: notARepo(repo) };
333
2152
  }
334
2153
  // Check if there are any file changes
335
2154
  const fileActions = actions.filter(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete' || a.type === 'mkdir');
336
2155
  if (fileActions.length === 0) {
337
2156
  return { success: false, error: 'No file changes to commit' };
338
2157
  }
339
- // Check for actual git changes
2158
+ // Check for actual git changes. A REFUSAL is read first: a refused
2159
+ // repository answers `hasChanges: undefined`, so the old order reported
2160
+ // "No changes detected by git" for a config Codeep would not run git under
2161
+ // — on the hottest path there is, the auto-commit at the end of every agent
2162
+ // run. The user then has an agent that silently stops committing and a
2163
+ // sentence that says nothing is wrong.
2164
+ //
2165
+ // `refusal` and NOT `error`, which was the fix's own bug: `error` is filled
2166
+ // for ANY git failure, and the commonest one is a brand-new repository with
2167
+ // no commit yet (`fatal: ambiguous argument 'HEAD'`, git 2.54). So `git
2168
+ // init` plus a first agent run printed raw git plumbing at a user who had
2169
+ // done nothing wrong. An ordinary failure keeps the ordinary sentence below.
340
2170
  const status = getGitStatus(cwd);
2171
+ if (status.refusal) {
2172
+ return { success: false, error: status.refusal };
2173
+ }
2174
+ // Reading ONLY `refusal` was the other half of the same mistake, in the
2175
+ // other direction: every git failure that is not a refusal came out as "No
2176
+ // changes detected by git". `hasChanges` is the field that says which one
2177
+ // this is, and it is undefined exactly when getGitStatus could not read the
2178
+ // working tree at all — the `git status --porcelain` it comes from threw,
2179
+ // so nobody knows whether there are changes. Reproduced with a repository
2180
+ // whose config says `core.bare = true`: git answers "fatal: this operation
2181
+ // must be run in a work tree", and the auto-commit told the user their
2182
+ // agent's work was not a change. Saying git failed, and what it said, is
2183
+ // the difference between a user who fixes their repository and one who
2184
+ // thinks Codeep wrote nothing.
2185
+ //
2186
+ // Ordered after the `hasChanges` read and before the `!hasChanges` one on
2187
+ // purpose: a brand-new repository fills `error` (there is no HEAD for the
2188
+ // branch read to name) while `hasChanges` is perfectly true, and that one
2189
+ // has to go on and commit.
2190
+ if (status.hasChanges === undefined) {
2191
+ return { success: false, error: `Could not read git status: ${status.error ?? 'git failed'}` };
2192
+ }
341
2193
  if (!status.hasChanges) {
342
2194
  return { success: false, error: 'No changes detected by git' };
343
2195
  }
344
- // Stage all changes
345
- if (!stageAll(cwd)) {
346
- return { success: false, error: 'Failed to stage changes' };
2196
+ // Stage all changes. The reason comes through rather than a fixed sentence:
2197
+ // a required content filter that cannot run is the case where "Failed to
2198
+ // stage changes" tells the user nothing and git's own line tells them
2199
+ // everything (see stageAllResult).
2200
+ const staged = stageAllResult(cwd);
2201
+ if (!staged.success) {
2202
+ return { success: false, error: staged.error ?? 'Failed to stage changes' };
347
2203
  }
348
2204
  // Generate commit message
349
2205
  const message = generateCommitMessage(prompt, actions);
@@ -371,8 +2227,9 @@ export function generateBranchName(prompt) {
371
2227
  * Create branch and commit agent changes
372
2228
  */
373
2229
  export function createBranchAndCommit(prompt, actions, cwd = process.cwd()) {
374
- if (!isGitRepository(cwd)) {
375
- return { success: false, error: 'Not a git repository' };
2230
+ const repo = checkGitRepository(cwd);
2231
+ if (!repo.isRepo) {
2232
+ return { success: false, error: notARepo(repo) };
376
2233
  }
377
2234
  // Generate branch name
378
2235
  const branchName = generateBranchName(prompt);
@@ -431,8 +2288,12 @@ const GIT_DIFF_FLAG_ALLOWLIST = new Set([
431
2288
  * Sync (spawn-based) so it slots into the mention-expansion pipeline.
432
2289
  */
433
2290
  export function getGitContent(ref, cwd = process.cwd()) {
434
- if (!isGitRepository(cwd)) {
435
- return { success: false, content: '', label: ref, error: 'not a git repository' };
2291
+ const repo = checkGitRepository(cwd);
2292
+ if (!repo.isRepo) {
2293
+ // Lower-cased default, because this one is inlined into a mention
2294
+ // failure line rather than shown on its own. A refusal keeps its own
2295
+ // capitalisation: it is a whole sentence with a fix in it.
2296
+ return { success: false, content: '', label: ref, error: repo.refusal ?? 'not a git repository' };
436
2297
  }
437
2298
  const trimmed = ref.trim();
438
2299
  if (!trimmed) {
@@ -481,11 +2342,17 @@ export function getGitContent(ref, cwd = process.cwd()) {
481
2342
  args = ['show', trimmed];
482
2343
  label = trimmed;
483
2344
  }
2345
+ // Both `diff` and `show` take these, and they belong right after the
2346
+ // subcommand so nothing the user wrote can come first: they stop a repo's
2347
+ // own diff drivers from running (see getGitDiff for what each one covers
2348
+ // and what a global textconv gives up).
2349
+ args.splice(1, 0, '--no-ext-diff', '--no-textconv');
484
2350
  try {
485
2351
  const out = execFileSync('git', args, {
486
2352
  cwd,
487
2353
  encoding: 'utf-8',
488
2354
  maxBuffer: 4 * 1024 * 1024,
2355
+ env: hardenedGitEnv({ cwd, noHooks: true }),
489
2356
  });
490
2357
  const content = (out ?? '').trimEnd();
491
2358
  if (!content) {