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.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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
|
-
|
|
1808
|
+
const repo = checkGitRepository(cwd);
|
|
1809
|
+
if (!repo.isRepo) {
|
|
74
1810
|
return {
|
|
75
1811
|
success: false,
|
|
76
1812
|
diff: '',
|
|
77
|
-
error:
|
|
1813
|
+
error: notARepo(repo),
|
|
78
1814
|
};
|
|
79
1815
|
}
|
|
80
1816
|
try {
|
|
81
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
1939
|
+
const repo = checkGitRepository(cwd);
|
|
1940
|
+
if (!repo.isRepo) {
|
|
170
1941
|
return {
|
|
171
1942
|
success: false,
|
|
172
|
-
error:
|
|
1943
|
+
error: notARepo(repo),
|
|
173
1944
|
};
|
|
174
1945
|
}
|
|
175
1946
|
try {
|
|
176
|
-
//
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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) {
|