@impeccable/reviews 0.1.9 → 0.1.11

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.
@@ -0,0 +1,96 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { request } from 'node:https';
3
+ import { BlockList, isIP } from 'node:net';
4
+ const blockedV4 = new BlockList();
5
+ for (const [address, prefix] of [
6
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
7
+ ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
8
+ ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
9
+ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
10
+ ])
11
+ blockedV4.addSubnet(address, prefix, 'ipv4');
12
+ const publicV6 = new BlockList();
13
+ publicV6.addSubnet('2000::', 3, 'ipv6');
14
+ const blockedV6 = new BlockList();
15
+ // Conservatively exclude special-purpose, transition and documentation prefixes.
16
+ for (const [address, prefix] of [
17
+ ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
18
+ ])
19
+ blockedV6.addSubnet(address, prefix, 'ipv6');
20
+ export function isPlatformPreview(target) {
21
+ return target.protocol === 'https:' && !target.username && !target.password && !target.port
22
+ && !/[?#]/.test(target.href)
23
+ && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
24
+ }
25
+ function isPublicAddress(value) {
26
+ if (!value || typeof value.address !== 'string' || value.address.includes('%'))
27
+ return false;
28
+ const family = isIP(value.address);
29
+ if (family !== value.family)
30
+ return false;
31
+ if (family === 4)
32
+ return !blockedV4.check(value.address, 'ipv4');
33
+ return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
34
+ }
35
+ /** Resolve once, validate every answer, then connect only to the pinned public addresses. */
36
+ export async function publicPreviewRequest(target, signal, dependencies = {}) {
37
+ try {
38
+ if (!isPlatformPreview(target))
39
+ throw new Error();
40
+ signal.throwIfAborted();
41
+ let abortDns = () => { };
42
+ const aborted = new Promise((_resolve, reject) => {
43
+ abortDns = () => reject(new Error());
44
+ signal.addEventListener('abort', abortDns, { once: true });
45
+ });
46
+ const addresses = await Promise.race([
47
+ (dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
48
+ ]).finally(() => signal.removeEventListener('abort', abortDns));
49
+ signal.throwIfAborted();
50
+ if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
51
+ throw new Error();
52
+ const pinned = addresses.map(({ address, family }) => ({ address, family }));
53
+ return await new Promise((resolve, reject) => {
54
+ const options = {
55
+ method: 'GET', headers: { accept: 'text/html' }, signal,
56
+ agent: false, autoSelectFamily: true, rejectUnauthorized: true,
57
+ lookup(hostname, options, callback) {
58
+ if (hostname !== target.hostname)
59
+ return callback(new Error('Preview request failed'), '');
60
+ if (options.all)
61
+ callback(null, pinned.map((address) => ({ ...address })));
62
+ else
63
+ callback(null, pinned[0].address, pinned[0].family);
64
+ },
65
+ };
66
+ const req = (dependencies.request ?? request)(target, options, (response) => {
67
+ response.once('error', () => { });
68
+ try {
69
+ const status = response.statusCode;
70
+ const headers = new Headers();
71
+ for (const name of ['location', 'x-vercel-error']) {
72
+ const value = response.headers[name];
73
+ if (typeof value === 'string')
74
+ headers.set(name, value);
75
+ }
76
+ if (!status)
77
+ reject(new Error());
78
+ else
79
+ resolve({ status, headers, body: null });
80
+ }
81
+ catch {
82
+ reject(new Error());
83
+ }
84
+ finally {
85
+ response.destroy();
86
+ }
87
+ });
88
+ req.once('error', () => reject(new Error()));
89
+ req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
90
+ req.end();
91
+ });
92
+ }
93
+ catch {
94
+ throw new Error('Preview request failed');
95
+ }
96
+ }
@@ -0,0 +1,46 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ const INTERVAL_MS = 30_000;
3
+ const DEADLINE_MS = 8 * 60_000;
4
+ /** One publication deadline per setup PR, including time spent checking the preview. */
5
+ export function createPublicationWait(dependencies = {}) {
6
+ const now = dependencies.now ?? (() => performance.now());
7
+ const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
8
+ const starts = new Map();
9
+ return {
10
+ begin(pr) {
11
+ if (!starts.has(pr))
12
+ starts.set(pr, now());
13
+ },
14
+ remainingMs(pr) {
15
+ const start = starts.get(pr);
16
+ return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
17
+ },
18
+ async wait(pr, signal) {
19
+ const start = starts.get(pr);
20
+ if (start === undefined)
21
+ return null;
22
+ const result = (status) => {
23
+ const elapsed = Math.max(0, now() - start);
24
+ return {
25
+ status,
26
+ waited_seconds: Math.floor(elapsed / 1000),
27
+ remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
28
+ };
29
+ };
30
+ if (signal?.aborted)
31
+ return result('cancelled');
32
+ const remaining = DEADLINE_MS - (now() - start);
33
+ if (remaining <= 0)
34
+ return result('timed-out');
35
+ try {
36
+ await pause(Math.min(INTERVAL_MS, remaining), signal);
37
+ }
38
+ catch {
39
+ return result('cancelled');
40
+ }
41
+ if (signal?.aborted)
42
+ return result('cancelled');
43
+ return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,121 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, realpath } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ const METADATA_LIMIT = 16 * 1024;
6
+ const OUTPUT_LIMIT = 2 * 1024 * 1024;
7
+ function record(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
9
+ ? value : undefined;
10
+ }
11
+ /** Only the root's small project-link metadata is read; CLI authentication stays with the CLI. */
12
+ async function linkedProject(cwd) {
13
+ try {
14
+ const root = await realpath(cwd);
15
+ const directory = join(root, '.vercel');
16
+ const directoryStat = await lstat(directory);
17
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
18
+ return;
19
+ const path = join(directory, 'project.json');
20
+ if (!(await lstat(path)).isFile())
21
+ return;
22
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
23
+ try {
24
+ const stat = await file.stat();
25
+ const currentDirectory = await lstat(directory);
26
+ const currentFile = await lstat(path);
27
+ if (!stat.isFile() || stat.size > METADATA_LIMIT
28
+ || currentDirectory.dev !== directoryStat.dev || currentDirectory.ino !== directoryStat.ino
29
+ || currentFile.isSymbolicLink() || currentFile.dev !== stat.dev || currentFile.ino !== stat.ino
30
+ || await realpath(path) !== path)
31
+ return;
32
+ const buffer = Buffer.alloc(METADATA_LIMIT + 1);
33
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
34
+ if (bytesRead > METADATA_LIMIT)
35
+ return;
36
+ const metadata = record(JSON.parse(buffer.toString('utf8', 0, bytesRead)));
37
+ if (typeof metadata?.projectId !== 'string' || !/^prj_[A-Za-z0-9]{1,128}$/.test(metadata.projectId)
38
+ || typeof metadata.orgId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(metadata.orgId))
39
+ return;
40
+ return { root, projectId: metadata.projectId, orgId: metadata.orgId };
41
+ }
42
+ finally {
43
+ await file.close();
44
+ }
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ }
50
+ /** Reuse one existing default automation bypass in memory, without exposing project responses or keys. */
51
+ export async function useExistingVercelBypass(input, dependencies) {
52
+ const { signal } = dependencies;
53
+ if (signal?.aborted)
54
+ return { status: 'cancelled' };
55
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input.owner)
56
+ || !/^[a-zA-Z0-9_.-]{1,100}$/.test(input.repo) || input.repo === '.' || input.repo === '..') {
57
+ return { status: 'project-mismatch' };
58
+ }
59
+ const linked = await linkedProject(input.cwd);
60
+ if (signal?.aborted)
61
+ return { status: 'cancelled' };
62
+ if (!linked)
63
+ return { status: 'not-linked' };
64
+ const runVercel = dependencies.runVercel ?? ((args) => new Promise((resolve, reject) => {
65
+ const child = execFile('vercel', args, {
66
+ cwd: linked.root, encoding: 'utf8', timeout: 30_000, maxBuffer: OUTPUT_LIMIT, signal,
67
+ }, (error, stdout) => error ? reject(new Error('Vercel project lookup failed.')) : resolve(stdout));
68
+ child.stdin?.end();
69
+ }));
70
+ // teamId selects the linked team. Legacy personal projects omit it and still require accountId to match.
71
+ const route = `/v9/projects/${linked.projectId}${linked.orgId.startsWith('team_') ? `?teamId=${linked.orgId}` : ''}`;
72
+ let selected;
73
+ try {
74
+ const stdout = await runVercel(['api', route, '--method', 'GET', '--raw', '--non-interactive'], signal);
75
+ if (signal?.aborted)
76
+ return { status: 'cancelled' };
77
+ if (Buffer.byteLength(stdout, 'utf8') > OUTPUT_LIMIT)
78
+ return { status: 'unavailable' };
79
+ const project = record(JSON.parse(stdout));
80
+ const link = record(project?.link);
81
+ if (project?.id !== linked.projectId || project.accountId !== linked.orgId
82
+ || !link || !['github', 'github-limited'].includes(String(link.type))
83
+ || typeof link.org !== 'string' || link.org.toLowerCase() !== input.owner.toLowerCase()
84
+ || typeof link.repo !== 'string' || link.repo.toLowerCase() !== input.repo.toLowerCase()) {
85
+ return { status: 'project-mismatch' };
86
+ }
87
+ const bypasses = record(project.protectionBypass);
88
+ if (!bypasses || Object.keys(bypasses).length > 128)
89
+ return { status: 'unavailable' };
90
+ const candidates = [];
91
+ for (const [value, metadata] of Object.entries(bypasses)) {
92
+ const bypass = record(metadata);
93
+ if (bypass?.scope !== 'automation-bypass')
94
+ continue;
95
+ if (!/^[A-Za-z0-9]{32}$/.test(value)
96
+ || (bypass.isEnvVar !== undefined && typeof bypass.isEnvVar !== 'boolean'))
97
+ return { status: 'unavailable' };
98
+ // An omitted isEnvVar is the legacy default. Explicit false keys must never be chosen implicitly.
99
+ if (bypass.isEnvVar !== false)
100
+ candidates.push(value);
101
+ }
102
+ if (candidates.length > 1)
103
+ return { status: 'ambiguous' };
104
+ if (candidates.length === 0)
105
+ return { status: 'unavailable' };
106
+ selected = candidates[0];
107
+ dependencies.rememberSecret(selected);
108
+ }
109
+ catch {
110
+ return { status: signal?.aborted ? 'cancelled' : 'unavailable' };
111
+ }
112
+ if (signal?.aborted)
113
+ return { status: 'cancelled' };
114
+ try {
115
+ const status = await dependencies.save(selected);
116
+ return { status: ['saved', 'save-failed', 'partial-save', 'cancelled'].includes(status) ? status : 'save-failed' };
117
+ }
118
+ catch {
119
+ return { status: signal?.aborted ? 'cancelled' : 'save-failed' };
120
+ }
121
+ }
package/dist/sandbox.js CHANGED
@@ -122,7 +122,8 @@ function moved(env, gitConfigs, kept) {
122
122
  }
123
123
  /**
124
124
  * What a command in the sandbox reads and writes: it reads what the person can but the credential folders, where their
125
- * environment moved them, and the files git's global settings come from (`gitConfigs`), and writes
125
+ * environment moved them, and the files git's global settings come from and the credential helper programs its system
126
+ * settings name (`gitConfigs`), and writes
126
127
  * in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
127
128
  * never where git reads settings or programs from, nor a .git below the checkout's top. Bubblewrap takes no pattern in
128
129
  * a write rule, and puts a file in place of a denied path that is not there: on Linux the names git reads as nothing
@@ -145,7 +146,7 @@ export function sandboxPolicy(o) {
145
146
  };
146
147
  }
147
148
  /**
148
- * The global git settings a command in the sandbox keeps: the person's name and email, what shapes how git reads the
149
+ * The git settings, global and system, a command in the sandbox keeps: the person's name and email, what shapes how git reads the
149
150
  * checkout (line endings, case, excludes, attributes, and the content filters, which git add runs: of their commands only
150
151
  * LFS's, see LFS_COMMAND, and every filter's `required`) and safe.directory. Nothing that carries or names a sign-in (http's
151
152
  * headers, proxy, cookies and certificates, url rewrites, credential helpers, sendemail's and imap's passwords, includes),
@@ -158,27 +159,46 @@ const KEPT_SETTINGS = /^(?:user\.(?:name|email)|init\.defaultbranch|core\.(?:aut
158
159
  * filtered rather than stage it unfiltered.
159
160
  */
160
161
  const LFS_COMMAND = /^git(?:-| +)lfs +(?:clean|smudge|filter-process)(?: +--skip)?(?: +(?:-- +)?%f)?$/;
161
- /** git's `-z --show-origin` listing, setting by setting: the file it came from, its key, and its value when it has one. */
162
+ /** git's `-z --show-scope --show-origin` listing, setting by setting: its scope, the file it came from, its key, and its value when it has one. */
162
163
  function* settings(listing) {
163
164
  const parts = listing.split('\0');
164
- for (let i = 0; i + 1 < parts.length; i += 2) {
165
- const origin = parts[i], entry = parts[i + 1];
165
+ for (let i = 0; i + 2 < parts.length; i += 3) {
166
+ const scope = parts[i], origin = parts[i + 1], entry = parts[i + 2];
166
167
  const at = entry.indexOf('\n');
167
- yield { file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
168
+ yield { scope, file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
168
169
  }
169
170
  }
170
171
  /**
171
- * The person's global git settings scrubbed for the sandbox, from git's own `-z --show-origin` listing of them (`file:path`,
172
- * NUL, the key, a newline and the value, NUL, per setting; the includes that hold for this checkout already read in): the
173
- * text of a config file that holds the kept settings, and the files they came from, for the deny list.
172
+ * The scopes outside the checkout, whose settings the sandbox's copy takes: the system config and the global one. Apple's
173
+ * git reads the config bundled with it (under Xcode or the Command Line Tools, `usr/share/git-core/gitconfig`, which names
174
+ * the osxkeychain credential helper) under no scope git has a name for, so `--show-scope` says `unknown`; GIT_CONFIG_NOSYSTEM
175
+ * turns that file off with /etc/gitconfig. The checkout's own settings (local, worktree) git in the sandbox reads as they
176
+ * are, and the command's own (`-c`, GIT_CONFIG_PARAMETERS) it gets in its environment.
177
+ */
178
+ const OUTSIDE_SCOPES = new Set(['system', 'unknown', 'global']);
179
+ /**
180
+ * The person's git settings scrubbed for the sandbox, from git's own `-z --show-scope --show-origin` listing of every scope
181
+ * (the scope, NUL, `file:path`, NUL, the key, a newline and the value, NUL, per setting; the includes that hold for this
182
+ * checkout already read in): the text of a config file that holds the kept settings of the system and global scopes, in
183
+ * git's order, so a global setting still wins over the system one, and the global files they came from, for the deny list.
184
+ * The system files (Apple git's under Xcode or the Command Line Tools, /etc/gitconfig) hold no sign-in of the person's and
185
+ * GIT_CONFIG_NOSYSTEM keeps git in the sandbox from reading them, so they are not denied; the checkout's own settings git
186
+ * reads from the checkout, which the copy does not repeat.
174
187
  */
175
188
  export function scrubGitConfig(listing) {
176
189
  const quoted = (text) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`;
177
190
  const files = new Set();
191
+ const helpers = new Set();
178
192
  const sections = new Map();
179
- for (const { file, key, value } of settings(listing)) {
180
- if (file)
193
+ for (const { scope, file, key, value } of settings(listing)) {
194
+ if (!OUTSIDE_SCOPES.has(scope))
195
+ continue;
196
+ if (file && scope === 'global')
181
197
  files.add(file);
198
+ // A system config's helper by its bare name (`osxkeychain`, `libsecret`): the program git-credential-<name> on git's exec path, which no sandboxed command is to run.
199
+ const named = scope !== 'global' && /^credential(?:\..+)?\.helper$/i.test(key) ? /^([^\s!/]+)(?:\s|$)/.exec(value ?? '')?.[1] : undefined;
200
+ if (named)
201
+ helpers.add(named);
182
202
  if (!KEPT_SETTINGS.test(key) || (/^filter\..+\.(?:clean|smudge|process)$/i.test(key) && !LFS_COMMAND.test(value ?? '')))
183
203
  continue;
184
204
  const section = key.slice(0, key.indexOf('.')), name = key.slice(key.lastIndexOf('.') + 1);
@@ -188,19 +208,23 @@ export function scrubGitConfig(listing) {
188
208
  lines.push(value === undefined ? `\t${name}` : `\t${name} = ${quoted(value)}`);
189
209
  sections.set(header, lines);
190
210
  }
191
- return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files] };
211
+ return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files], helpers: [...helpers] };
192
212
  }
213
+ /** Where Apple's gits keep their programs, git-credential-osxkeychain among them: the one xcode-select names answers `git --exec-path`, the other stays, and a script could run either. */
214
+ const APPLE_GIT_CORE = ['/Applications/Xcode.app/Contents/Developer/usr/libexec/git-core', '/Library/Developer/CommandLineTools/usr/libexec/git-core'];
193
215
  /** A setting that names a file git includes: include.path, and includeIf.<condition>.path, whose condition may not hold for this checkout. */
194
216
  const INCLUDE = /^include(?:if\..*)?\.path$/i;
195
217
  /**
196
218
  * Every file the settings in a listing include, whether the include holds for this checkout or not, since a token sits as
197
219
  * readily behind an includeIf for another one: `~/` from the home folder, a relative path from the including file's folder,
198
- * an absolute path as it is, and never another user's `~user/`, which only git resolves.
220
+ * an absolute path as it is, and never another user's `~user/`, which only git resolves. Of one scope's settings only when
221
+ * `scope` is given: a listing of every scope carries the checkout's own includes too, which git in the sandbox reads as
222
+ * they are, so they are not for the deny list.
199
223
  */
200
- export function gitIncludes(listing, home) {
224
+ export function gitIncludes(listing, home, scope) {
201
225
  const named = new Set();
202
- for (const { file, key, value } of settings(listing)) {
203
- if (!INCLUDE.test(key) || value === undefined)
226
+ for (const { scope: own, file, key, value } of settings(listing)) {
227
+ if ((scope !== undefined && own !== scope) || !INCLUDE.test(key) || value === undefined)
204
228
  continue;
205
229
  if (value.startsWith('~/'))
206
230
  named.add(join(home, value.slice(2)));
@@ -212,46 +236,55 @@ export function gitIncludes(listing, home) {
212
236
  return [...named];
213
237
  }
214
238
  /**
215
- * The scrubbed copy of the person's global git settings, read as the person from the checkout (an includeIf for it holds)
216
- * and written where no sandboxed command writes, and the files the settings came from or include, held or not, each read
217
- * for what it includes in turn. Without a global config git says so and exits 128, and the copy is empty: git in the
218
- * sandbox then reads the checkout's own settings only. A global config or an include that holds for this checkout that git
219
- * cannot read otherwise (a bad line, a file it may not open) stops the run, with git's words: what it includes in turn
220
- * would stay readable, unknown to the deny list. One held for another checkout only is named in the debug log and stays
221
- * denied, and the run goes on, though what it includes in turn stays unknown.
239
+ * The scrubbed copy of the person's git settings, system and global, read as the person from the checkout (an includeIf
240
+ * for it holds): the system scope from a listing of every scope with the global config turned off, since git lists the
241
+ * system config Apple's git reads under no scope's name only without a scope flag, and `--system` names /etc/gitconfig
242
+ * alone; the global scope from its own listing, which dies on a global config git may not open where the listing of every
243
+ * scope would skip it. Written where no sandboxed command writes; and the global files the settings came from or include,
244
+ * held or not, each read for what it includes in turn. Without a global config git says so and exits 128, and the copy
245
+ * holds the system settings alone, if any: git in the sandbox then reads the checkout's own settings besides. A config or
246
+ * an include that holds for this checkout that git cannot read otherwise (a bad line, a file it may not open) stops the
247
+ * run, with git's words: what it includes in turn would stay readable, unknown to the deny list. One held for another
248
+ * checkout only is named in the debug log and stays denied, and the run goes on, though what it includes in turn stays
249
+ * unknown. Also the credential helper programs the system config names, where they are (git's exec path, Apple's two), for
250
+ * the deny list: GIT_CONFIG_NOSYSTEM keeps git in the sandbox from naming them, and a script that unsets it, or runs one by
251
+ * its path, finds the program unreadable, so no keychain is asked (the denied keychain folder would answer it nothing anyway).
222
252
  */
223
253
  function scrubbedGitConfig(root, home, log) {
224
254
  const env = gitEnvironment({ ...process.env, HOME: home });
225
- const list = (args, unread) => {
255
+ const list = (args, o = {}) => {
226
256
  try {
227
- return execFileSync('git', ['config', ...args, '-z', '--list', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env });
257
+ return execFileSync('git', ['config', ...args, '-z', '--list', '--show-scope', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env: { ...env, ...o.env } });
228
258
  }
229
259
  catch (err) {
230
260
  const said = String(err.stderr ?? err.message).trim().split('\n').pop() ?? '';
231
261
  if (/unable to read config file .*: No such file or directory/.test(said))
232
262
  return '';
233
- if (unread) {
234
- unread(said);
263
+ if (o.unread) {
264
+ o.unread(said);
235
265
  return '';
236
266
  }
237
- throw new UserError(`Impeccable keeps your global git settings and the files they include out of the sandbox, and git could not read them (${said}). Fix that file, or the setting that includes it, and run impeccable-reviews again.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
267
+ throw new UserError(`Impeccable keeps your git settings and the files they include out of the sandbox, and git could not read them (${said}). Fix that file, or the setting that includes it, and run impeccable-reviews again.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
238
268
  }
239
269
  };
240
- const listing = list(['--global', '--includes']);
241
- const { text, files } = scrubGitConfig(listing);
270
+ // The system scope first, so a global setting wins over the system one in the copy as it does for git.
271
+ const listing = list(['--includes'], { env: { GIT_CONFIG_GLOBAL: '/dev/null' } }) + list(['--global', '--includes']);
272
+ const { text, files, helpers } = scrubGitConfig(listing);
242
273
  const seen = new Set(files);
243
- const includes = gitIncludes(listing, home);
274
+ const includes = gitIncludes(listing, home, 'global');
244
275
  for (const file of includes) {
245
276
  if (seen.has(file))
246
277
  continue;
247
278
  seen.add(file);
248
279
  // The listing above read every include that holds for this checkout and stopped on one git cannot read, so one that
249
280
  // fails here holds for another checkout only.
250
- includes.push(...gitIncludes(list(['--file', file, '--no-includes'], (said) => log.write('git include not read', { file, said })), home));
281
+ includes.push(...gitIncludes(list(['--file', file, '--no-includes'], { unread: (said) => log.write('git include not read', { file, said }) }), home));
251
282
  }
252
283
  const path = join(ownFolder(), `gitconfig-${randomBytes(6).toString('hex')}`);
253
284
  writeFileSync(path, text, { mode: 0o600 });
254
- return { path, files: [...seen] };
285
+ const execPath = execFileSync('git', ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env }).trim();
286
+ const programs = helpers.flatMap((name) => [...new Set([execPath, ...APPLE_GIT_CORE])].map((dir) => join(dir, `git-credential-${name}`))).filter(present);
287
+ return { path, files: [...seen], helpers: programs };
255
288
  }
256
289
  /** Whether `path` is there, a dangling symlink too. */
257
290
  function present(path) {
@@ -379,12 +412,23 @@ export function allowedDomains(s) {
379
412
  names.push(...(HOST_APIS[agreed] ?? []));
380
413
  return [...new Set(names)];
381
414
  }
415
+ /**
416
+ * Nothing that asks for a password, a passphrase or a host's key, on the terminal, in a window or in Git Credential Manager's:
417
+ * the fence's own reads of the remote run with it, and so does every command in the sandbox, where a `git ls-remote` of a
418
+ * repository git has no sign-in for would otherwise wait on a prompt in the person's terminal, where the screen is drawing.
419
+ */
420
+ export const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never' };
382
421
  /**
383
422
  * What a command in the sandbox also gets: Node's fetch through the sandbox's proxy; no browser downloads, which land in
384
- * caches it does not write, from hosts it does not reach; and no git hooks installed by husky, which writes the git config
385
- * the sandbox keeps.
423
+ * caches it does not write, from hosts it does not reach; no git hooks installed by husky, which writes the git config
424
+ * the sandbox keeps; no prompt for a sign-in; and no system git config, which the sandbox lets git read (Apple's sits
425
+ * under Xcode or the Command Line Tools, not in a denied folder) and which names the osxkeychain credential helper there,
426
+ * so that git in the sandbox has no credential helper at all, as it has none from the global config: it asks for no
427
+ * sign-in rather than fail in a helper. What keeps the sign-in from a script is the denied keychain folder, which holds
428
+ * for a script that unsets this variable too (the login keychain, where git's helper and gh keep their items, is a file
429
+ * the asking process opens, so the helper and `security` find no item); this variable takes the asking away. The kept settings come through the scrubbed copy.
386
430
  */
387
- const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
431
+ const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0', GIT_CONFIG_NOSYSTEM: '1', ...NO_PROMPT };
388
432
  /** And, for the agent's shell, npm's cache and corepack's package managers in the run's scratch folder: what a sandboxed install fetched stays with the run, and ~/.npm, which the person's npm and npx read from, is never written. */
389
433
  const scratchCaches = (scratch) => ({ npm_config_cache: join(scratch, 'npm-cache'), COREPACK_HOME: join(scratch, 'corepack'), COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' });
390
434
  /**
@@ -468,7 +512,7 @@ export async function startSandbox(o) {
468
512
  }
469
513
  })();
470
514
  const global = scrubbedGitConfig(git.root, home, o.log);
471
- const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: global.files });
515
+ const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: [...global.files, ...global.helpers] });
472
516
  const files = checkoutFiles(git.root);
473
517
  const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
474
518
  let domains = allowedDomains({ ...sources, hosts: o.hosts() });
@@ -562,7 +606,7 @@ export async function startSandbox(o) {
562
606
  const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
563
607
  running++;
564
608
  try {
565
- // Git in the sandbox reads the scrubbed copy of the global settings: the person's own are denied, and git dies on a global config it cannot read.
609
+ // Git in the sandbox reads the scrubbed copy of the system and global settings: the person's own global files are denied, the system ones turned off (INSIDE), and git dies on a global config it cannot read.
566
610
  return await exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE, ...scratchCaches(o.scratch), ...pnpmEnvironment(home, platform), GIT_CONFIG_GLOBAL: global.path } });
567
611
  }
568
612
  finally {