@impeccable/reviews 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +21 -20
  2. package/dist/agent.js +9 -4
  3. package/dist/cloudflare-access.js +77 -47
  4. package/dist/cloudflare.js +325 -23
  5. package/dist/fence.js +939 -143
  6. package/dist/focus.js +5 -4
  7. package/dist/host-previews.js +107 -0
  8. package/dist/host-questions.js +90 -20
  9. package/dist/hosted.js +72 -21
  10. package/dist/http.js +2 -0
  11. package/dist/log.js +7 -1
  12. package/dist/login.js +45 -13
  13. package/dist/main.js +29 -14
  14. package/dist/netlify-password.js +8 -6
  15. package/dist/netlify.js +43 -14
  16. package/dist/pi-paths.js +69 -0
  17. package/dist/playbook/cases/cloudflare-previews.md +12 -4
  18. package/dist/playbook/cases/first-preview-failure.md +1 -1
  19. package/dist/playbook/cases/live-app-address.md +15 -0
  20. package/dist/playbook/cases/monorepo.md +1 -1
  21. package/dist/playbook/cases/netlify-previews.md +3 -1
  22. package/dist/playbook/cases/netlify-setup.md +7 -6
  23. package/dist/playbook/cases/preview-protection.md +2 -1
  24. package/dist/playbook/cases/pull-requests.md +3 -3
  25. package/dist/playbook/cases/static-site.md +2 -2
  26. package/dist/playbook/cases/vercel-previews.md +2 -0
  27. package/dist/playbook/cases/vercel-setup.md +8 -5
  28. package/dist/playbook.js +2 -2
  29. package/dist/preflight.js +36 -7
  30. package/dist/report.js +1 -0
  31. package/dist/sandbox.js +364 -50
  32. package/dist/scan.js +134 -0
  33. package/dist/sync.js +44 -7
  34. package/dist/tools.js +377 -88
  35. package/dist/ui/bridge.js +18 -4
  36. package/dist/ui/look.js +1 -1
  37. package/dist/ui/screens.js +5 -3
  38. package/dist/ui/sensitive.js +10 -0
  39. package/dist/vercel-bypass.js +12 -7
  40. package/dist/vercel.js +110 -17
  41. package/package.json +1 -1
package/dist/sandbox.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { homedir, tmpdir } from 'node:os';
6
- import { join, resolve } from 'node:path';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
8
8
  import { createLocalBashOperations } from '@earendil-works/pi-coding-agent';
9
9
  import { UserError } from './log.js';
@@ -26,53 +26,302 @@ export function ownFolder() {
26
26
  }
27
27
  return own;
28
28
  }
29
+ /** npm's cache for what runs from there: a host CLI's install and npx for cf fetch through it, and never through ~/.npm, whose entries a sandboxed install could have planted for them. */
30
+ export const ownNpmCache = () => join(ownFolder(), 'npm-cache');
29
31
  /**
30
32
  * The environment of every git that runs outside the sandbox, the command's own and the person's commands (gh and the
31
- * host CLIs run git too): no hooks and no fsmonitor, whose programs a sandboxed command could have written, and, once
33
+ * host CLIs run git too): no hooks and no fsmonitor, whose programs a sandboxed command could have written, no replacement
34
+ * objects, whose refs it could have written to show a commit other than the one a push sends, and, once
32
35
  * the sandbox started, this checkout's git folders named rather than found, so a .git or a commondir a sandboxed
33
- * command made is never what git reads.
36
+ * command made is never what git reads. Nor GH_REPO and GH_HOST, which would aim gh at another repository or host than
37
+ * this checkout's, as the person.
34
38
  */
35
- export function gitEnvironment(env = process.env) {
39
+ export function gitEnvironment({ GH_REPO, GH_HOST, ...env } = process.env) {
36
40
  const settings = [env.GIT_CONFIG_PARAMETERS, "'core.hooksPath=/dev/null' 'core.fsmonitor=false'"].filter(Boolean).join(' ');
37
- return { ...env, GIT_CONFIG_PARAMETERS: settings, ...(pinned ? { GIT_DIR: pinned.gitDir, GIT_COMMON_DIR: pinned.commonDir, GIT_WORK_TREE: pinned.root } : {}) };
41
+ return { ...env, GIT_CONFIG_PARAMETERS: settings, GIT_NO_REPLACE_OBJECTS: '1', ...(pinned ? { GIT_DIR: pinned.gitDir, GIT_COMMON_DIR: pinned.commonDir, GIT_WORK_TREE: pinned.root } : {}) };
38
42
  }
39
- /** Credential folders and files, from the home folder, that no command in the sandbox reads: SSH's, GPG's and the cloud CLIs' keys, and git's, gh's and Docker's sign-ins. */
40
- const CREDENTIALS = ['.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.kube', '.docker', '.netrc', '.git-credentials', '.config/gh'];
41
- /** The package managers' caches and stores, which an install writes. */
42
- const CACHES = ['.npm', '.pnpm-store', '.local/share/pnpm', '.cache/pnpm', '.cache/yarn', '.yarn/berry', '.bun/install/cache', '.cache/node/corepack', '.cache/prisma'];
43
- /** macOS keeps the keychains, and some of those caches, under Library. */
44
- const MACOS = { credentials: ['Library/Keychains'], caches: ['Library/pnpm', 'Library/Caches/pnpm', 'Library/Caches/Yarn'] };
45
43
  /**
46
- * What a command in the sandbox reads and writes: it reads what the person can but the credential folders, and writes
44
+ * The shells' rc files, which often export tokens, and which no shell the sandbox runs reads: the agent's is bash -c, with
45
+ * no BASH_ENV, and zsh -c skips a denied .zshenv as it skips an absent one.
46
+ */
47
+ const SHELL_RC = ['.bashrc', '.bash_profile', '.bash_login', '.bash_logout', '.profile', '.zshenv', '.zprofile', '.zshrc', '.zlogin', '.zlogout'];
48
+ /**
49
+ * Credential folders and files, from the home folder, that no command in the sandbox reads: SSH's, GPG's and the cloud CLIs' keys;
50
+ * git's, gh's and Docker's sign-ins; git's global settings, whose headers and url rewrites carry tokens (git in the sandbox gets a
51
+ * scrubbed copy); Vercel's, Netlify's and Cloudflare's CLIs' sign-ins; the registries' publishing tokens; the coding agents'
52
+ * sign-ins; the shells' rc files and histories; and the browsers' profiles. ~/.npmrc stays readable: the sandbox's hosts come
53
+ * from its registries, and npm needs its token for them.
54
+ */
55
+ const CREDENTIALS = [
56
+ '.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.kube', '.docker', '.netrc', '.git-credentials', '.config/gh',
57
+ '.gitconfig', '.config/git/config', '.config/git/credentials',
58
+ '.local/share/com.vercel.cli', '.vercel', '.config/netlify', '.netlify', '.config/.wrangler', '.wrangler',
59
+ '.yarnrc.yml', '.pypirc', '.cargo/credentials', '.cargo/credentials.toml', '.gem/credentials', '.m2/settings.xml', '.gradle/gradle.properties', '.terraform.d',
60
+ '.codex', '.claude',
61
+ ...SHELL_RC, '.config/fish', '.bash_history', '.zsh_history', '.zsh_sessions', '.local/share/fish/fish_history',
62
+ '.config/google-chrome', '.config/chromium', '.config/BraveSoftware', '.config/microsoft-edge', '.mozilla',
63
+ ];
64
+ /**
65
+ * The variables that move a credential store or a shell's files, and what the sandbox denies under the folder each names: the
66
+ * store itself (KUBECONFIG names a list of files); cargo's credentials; zsh's rc files under ZDOTDIR; and under XDG_CONFIG_HOME
67
+ * and XDG_DATA_HOME what is denied under ~/.config and ~/.local/share. The variables themselves stay in the sandbox's
68
+ * environment: gh and the host CLIs read them outside the sandbox, and the package managers inside it.
69
+ */
70
+ const under = (prefix) => CREDENTIALS.filter((path) => path.startsWith(prefix)).map((path) => path.slice(prefix.length));
71
+ const MOVED = {
72
+ GH_CONFIG_DIR: [''], VERCEL_AUTH_CONFIG_DIR: [''], GNUPGHOME: [''], DOCKER_CONFIG: [''], CLOUDSDK_CONFIG: [''], AZURE_CONFIG_DIR: [''], KUBECONFIG: [''],
73
+ CODEX_HOME: [''], CLAUDE_CONFIG_DIR: [''], GIT_CONFIG_GLOBAL: [''], HISTFILE: [''],
74
+ CARGO_HOME: ['credentials', 'credentials.toml'], ZDOTDIR: SHELL_RC.filter((name) => name.startsWith('.z')),
75
+ XDG_CONFIG_HOME: under('.config/'), XDG_DATA_HOME: under('.local/share/'),
76
+ };
77
+ /**
78
+ * The package managers' caches and stores, which an install writes: pnpm's store alone under PNPM_HOME, whose global
79
+ * bins are on PATH, and neither npm's cache nor corepack's, which the sandbox's commands keep in the scratch folder, since
80
+ * the host CLIs' installs and npx run outside it as the person. Nor pnpm's metadata cache, whose dlx folder the person's
81
+ * next `pnpm dlx` runs from: pnpm reads it when it can and goes on when it cannot write it (pnpm 11).
82
+ */
83
+ const CACHES = ['.pnpm-store', '.local/share/pnpm/store', '.cache/yarn', '.yarn/berry', '.bun/install/cache', '.cache/prisma'];
84
+ /** macOS keeps the keychains, the host CLIs' sign-ins, the browsers' profiles and cookies, and some of those caches, under Library. */
85
+ const MACOS = {
86
+ credentials: [
87
+ 'Library/Keychains', 'Library/Application Support/com.vercel.cli', 'Library/Preferences/netlify', 'Library/Preferences/.wrangler',
88
+ 'Library/Application Support/Google/Chrome', 'Library/Application Support/Chromium', 'Library/Application Support/BraveSoftware', 'Library/Application Support/Microsoft Edge', 'Library/Application Support/Firefox', 'Library/Cookies',
89
+ ],
90
+ caches: ['Library/pnpm/store', 'Library/Caches/Yarn'],
91
+ };
92
+ /**
93
+ * Where git takes settings or programs from, in this checkout's git folders: its config, hooks, info and submodules'
94
+ * folders, a commondir, the other worktrees' git folders, and the .git file of a worktree, which names its folder. No
95
+ * command in the sandbox writes them, and the run checks them at its end.
96
+ */
97
+ function gitSettings({ root, gitDir, commonDir }, worktrees) {
98
+ return [...new Set([
99
+ ...['config', 'config.worktree', 'hooks', 'info', 'modules'].map((name) => join(commonDir, name)),
100
+ ...['commondir', 'config.worktree'].map((name) => join(gitDir, name)),
101
+ ...worktrees.map((name) => join(commonDir, 'worktrees', name)).filter((folder) => folder !== gitDir),
102
+ ...(gitDir === join(root, '.git') ? [] : [join(root, '.git')]),
103
+ ])];
104
+ }
105
+ /** The names bubblewrap may put an empty file in place of, when absent, which git reads as nothing; a commondir it dies on. */
106
+ const EMPTY_TO_GIT = new Set(['config.worktree', 'hooks', 'info', 'modules']);
107
+ /**
108
+ * What the environment's location variables put out of the sandbox's reach, by absolute path, and the files git's global
109
+ * settings came from: never a folder the checkout, the scratch folder or the home folder is in, which a variable set to
110
+ * the home folder itself would name, and which every command reads.
111
+ */
112
+ function moved(env, gitConfigs, kept) {
113
+ const holds = (path) => kept.some((folder) => {
114
+ const below = relative(path, folder);
115
+ return below === '' || (!below.startsWith('..') && !isAbsolute(below));
116
+ });
117
+ const named = Object.entries(MOVED).flatMap(([name, below]) => {
118
+ const value = env[name] ?? '';
119
+ return (name === 'KUBECONFIG' ? value.split(':') : [value]).filter(isAbsolute).flatMap((folder) => below.map((path) => resolve(folder, path)));
120
+ });
121
+ return [...named, ...gitConfigs].filter((path) => !holds(path));
122
+ }
123
+ /**
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
47
126
  * in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
48
- * never where git reads settings or programs from: config, hooks, info, a commondir, the submodules' and the other
49
- * worktrees' git folders, and a .git below the checkout's top. Bubblewrap takes no pattern in a write rule, and puts a
50
- * file in place of a denied path that is not there, which git would read as an empty commondir: on Linux only what is
51
- * there is denied, and the git that runs outside is pinned to this checkout's folders instead.
127
+ * never where git reads settings or programs from, nor a .git below the checkout's top. Bubblewrap takes no pattern in
128
+ * 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
129
+ * when empty are denied whether there or not, a commondir only when it is there, and a .git below the top not at all;
130
+ * the git that runs outside is pinned to this checkout's folders instead, and the run's end removes what appeared.
52
131
  */
53
132
  export function sandboxPolicy(o) {
54
133
  const macos = o.platform === 'darwin';
55
134
  const { root, gitDir, commonDir } = o.git;
56
135
  const inHome = (paths) => paths.map((path) => join(o.home, path));
57
136
  const denyWrite = [
58
- ...['config', 'config.worktree', 'hooks', 'info', 'modules'].map((name) => join(commonDir, name)),
59
- ...['commondir', 'config.worktree'].map((name) => join(gitDir, name)),
60
- ...o.worktrees.flatMap((name) => ['commondir', 'config.worktree'].map((file) => join(commonDir, 'worktrees', name, file))),
61
- // A worktree's or a submodule's .git is a file that names its git folder.
62
- ...(gitDir === join(root, '.git') ? [] : [join(root, '.git')]),
137
+ ...gitSettings(o.git, o.worktrees),
63
138
  ...(macos ? [join(root, '*', '**', '.git')] : []),
64
- // Inside npm's cache, which installs write: what npx installed, which the command's own tools run outside the sandbox.
65
- join(o.home, '.npm', '_npx'),
66
139
  ];
67
140
  const exists = o.exists ?? existsSync;
68
141
  return {
69
- denyRead: inHome([...CREDENTIALS, ...(macos ? MACOS.credentials : [])]),
142
+ denyRead: [...new Set([...inHome([...CREDENTIALS, ...(macos ? MACOS.credentials : [])]), ...moved(o.env ?? process.env, o.gitConfigs ?? [], [root, o.scratch, o.home])])],
70
143
  allowWrite: [...new Set([root, gitDir, commonDir, o.scratch, ...inHome([...CACHES, ...(macos ? MACOS.caches : [])])])],
71
- denyWrite: [...new Set(macos ? denyWrite : denyWrite.filter((path) => exists(path)))],
144
+ denyWrite: [...new Set(macos ? denyWrite : denyWrite.filter((path) => exists(path) || EMPTY_TO_GIT.has(basename(path))))],
145
+ };
146
+ }
147
+ /**
148
+ * The global git settings a command in the sandbox keeps: the person's name and email, what shapes how git reads the
149
+ * checkout (line endings, case, excludes, attributes, and the content filters, which git add runs: of their commands only
150
+ * LFS's, see LFS_COMMAND, and every filter's `required`) and safe.directory. Nothing that carries or names a sign-in (http's
151
+ * headers, proxy, cookies and certificates, url rewrites, credential helpers, sendemail's and imap's passwords, includes),
152
+ * and no alias or program, which a key not listed could name.
153
+ */
154
+ const KEPT_SETTINGS = /^(?:user\.(?:name|email)|init\.defaultbranch|core\.(?:autocrlf|eol|safecrlf|ignorecase|filemode|symlinks|quotepath|precomposeunicode|excludesfile|attributesfile|longpaths|untrackedcache)|safe\.directory|filter\..+\.(?:clean|smudge|process|required))$/i;
155
+ /**
156
+ * The filter commands kept, LFS's as `git lfs install` writes them, whole: any other filter command is a program whose
157
+ * arguments could carry a password. A filter whose command is dropped keeps `required`, so git refuses a file it would have
158
+ * filtered rather than stage it unfiltered.
159
+ */
160
+ 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
+ function* settings(listing) {
163
+ 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];
166
+ 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
+ }
169
+ }
170
+ /**
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.
174
+ */
175
+ export function scrubGitConfig(listing) {
176
+ const quoted = (text) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`;
177
+ const files = new Set();
178
+ const sections = new Map();
179
+ for (const { file, key, value } of settings(listing)) {
180
+ if (file)
181
+ files.add(file);
182
+ if (!KEPT_SETTINGS.test(key) || (/^filter\..+\.(?:clean|smudge|process)$/i.test(key) && !LFS_COMMAND.test(value ?? '')))
183
+ continue;
184
+ const section = key.slice(0, key.indexOf('.')), name = key.slice(key.lastIndexOf('.') + 1);
185
+ const subsection = key.slice(section.length + 1, key.length - name.length - 1);
186
+ const header = subsection ? `[${section} ${quoted(subsection)}]` : `[${section}]`;
187
+ const lines = sections.get(header) ?? [];
188
+ lines.push(value === undefined ? `\t${name}` : `\t${name} = ${quoted(value)}`);
189
+ sections.set(header, lines);
190
+ }
191
+ return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files] };
192
+ }
193
+ /** A setting that names a file git includes: include.path, and includeIf.<condition>.path, whose condition may not hold for this checkout. */
194
+ const INCLUDE = /^include(?:if\..*)?\.path$/i;
195
+ /**
196
+ * Every file the settings in a listing include, whether the include holds for this checkout or not, since a token sits as
197
+ * 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.
199
+ */
200
+ export function gitIncludes(listing, home) {
201
+ const named = new Set();
202
+ for (const { file, key, value } of settings(listing)) {
203
+ if (!INCLUDE.test(key) || value === undefined)
204
+ continue;
205
+ if (value.startsWith('~/'))
206
+ named.add(join(home, value.slice(2)));
207
+ else if (isAbsolute(value))
208
+ named.add(value);
209
+ else if (file && !value.startsWith('~'))
210
+ named.add(resolve(file, '..', value));
211
+ }
212
+ return [...named];
213
+ }
214
+ /**
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.
222
+ */
223
+ function scrubbedGitConfig(root, home, log) {
224
+ const env = gitEnvironment({ ...process.env, HOME: home });
225
+ const list = (args, unread) => {
226
+ try {
227
+ return execFileSync('git', ['config', ...args, '-z', '--list', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env });
228
+ }
229
+ catch (err) {
230
+ const said = String(err.stderr ?? err.message).trim().split('\n').pop() ?? '';
231
+ if (/unable to read config file .*: No such file or directory/.test(said))
232
+ return '';
233
+ if (unread) {
234
+ unread(said);
235
+ return '';
236
+ }
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' });
238
+ }
239
+ };
240
+ const listing = list(['--global', '--includes']);
241
+ const { text, files } = scrubGitConfig(listing);
242
+ const seen = new Set(files);
243
+ const includes = gitIncludes(listing, home);
244
+ for (const file of includes) {
245
+ if (seen.has(file))
246
+ continue;
247
+ seen.add(file);
248
+ // The listing above read every include that holds for this checkout and stopped on one git cannot read, so one that
249
+ // 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));
251
+ }
252
+ const path = join(ownFolder(), `gitconfig-${randomBytes(6).toString('hex')}`);
253
+ writeFileSync(path, text, { mode: 0o600 });
254
+ return { path, files: [...seen] };
255
+ }
256
+ /** Whether `path` is there, a dangling symlink too. */
257
+ function present(path) {
258
+ try {
259
+ lstatSync(path);
260
+ return true;
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ /** The .git entries below the checkout's top, files and folders, by absolute path; node_modules is not searched, and no symlink is followed. */
267
+ function nestedGits(root) {
268
+ const found = [];
269
+ const walk = (folder, top) => {
270
+ let entries;
271
+ try {
272
+ entries = readdirSync(folder, { withFileTypes: true });
273
+ }
274
+ catch {
275
+ return;
276
+ }
277
+ for (const entry of entries) {
278
+ const path = join(folder, entry.name);
279
+ if (entry.name === '.git') {
280
+ if (!top)
281
+ found.push(path);
282
+ }
283
+ else if (entry.isDirectory() && entry.name !== 'node_modules')
284
+ walk(path, false);
285
+ }
72
286
  };
287
+ walk(root, true);
288
+ return found;
73
289
  }
74
- /** GitHub and the hosts it serves files from, for a dependency on GitHub. */
75
- const GITHUB = ['github.com', '*.github.com', '*.githubusercontent.com'];
290
+ /**
291
+ * The git folders watched over the run, from before any command runs: `settle` removes what appeared since where git
292
+ * takes settings or programs from (a commondir, a config.worktree, hooks, a .git below the checkout's top), which on
293
+ * Linux the sandbox cannot deny when absent, and which nothing denies without a sandbox, and names each removal for the
294
+ * summary. The person's own git would read them after the run, with all their access.
295
+ */
296
+ export function watchGit(git, worktrees) {
297
+ const settings = gitSettings(git, worktrees);
298
+ const before = new Set([...settings.filter(present), ...nestedGits(git.root)]);
299
+ return {
300
+ settle() {
301
+ const appeared = [...settings, ...nestedGits(git.root)].filter((path) => !before.has(path) && present(path));
302
+ return appeared.map((path) => {
303
+ const made = `A command made ${relative(git.root, path)} during the run, which git would take settings or programs from`;
304
+ try {
305
+ rmSync(path, { recursive: true, force: true });
306
+ return `${made}, so it was removed.`;
307
+ }
308
+ catch (err) {
309
+ return `${made}, and it could not be removed (${err.code ?? 'unknown error'}): remove it yourself before running git there.`;
310
+ }
311
+ });
312
+ },
313
+ };
314
+ }
315
+ /**
316
+ * GitHub's hosts an install of a dependency on GitHub fetches from: git and archives from github.com, tarballs from codeload,
317
+ * release assets and raw files from githubusercontent, and bun's tarballs from the API. Gists' and uploads' hosts are not among them.
318
+ */
319
+ const GITHUB = ['github.com', 'api.github.com', 'codeload.github.com', '*.githubusercontent.com'];
320
+ /**
321
+ * What no command in the sandbox reaches on an allowed host: GitHub over SSH, which git would take through the sandbox's
322
+ * SOCKS proxy to push with a key a script carries. The reason reaches the client in SSH's own disconnect, so git prints it.
323
+ */
324
+ const DENIED = { deniedDomains: ['github.com:22'], deniedDomainReasons: { 'github.com:22': 'SSH to GitHub is refused in the sandbox; what pushes runs outside it, as the person.' } };
76
325
  /** The public registries a kind of manifest or lockfile installs from. */
77
326
  const REGISTRIES = [
78
327
  [/(?:^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|bun\.lockb?)$/, ['registry.npmjs.org']],
@@ -136,8 +385,30 @@ export function allowedDomains(s) {
136
385
  * the sandbox keeps.
137
386
  */
138
387
  const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
388
+ /** 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
+ const scratchCaches = (scratch) => ({ npm_config_cache: join(scratch, 'npm-cache'), COREPACK_HOME: join(scratch, 'corepack'), COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' });
390
+ /**
391
+ * And pnpm's store, the person's, named: pnpm 11 finds it by writing in pnpm's home, which the sandbox refuses, and
392
+ * otherwise makes a store of its own in the checkout. What an install puts in node_modules is copied from it, never
393
+ * hard-linked, so a write through an installed file changes neither the store nor a global command linked from it.
394
+ */
395
+ export const pnpmEnvironment = (home, platform) => ({ pnpm_config_store_dir: join(home, platform === 'darwin' ? 'Library/pnpm/store' : '.local/share/pnpm/store'), pnpm_config_package_import_method: 'copy' });
396
+ /** For a host CLI's install outside the sandbox: npm's cache of the command's own, and the registry asked afresh for what it has. */
397
+ const installEnvironment = () => ({ npm_config_cache: ownNpmCache(), npm_config_prefer_online: 'true' });
139
398
  /** The sandbox's own command line, which the local preview check runs each command through, in a sandbox of its own. */
140
399
  const SRT = createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/dist/cli.js');
400
+ /**
401
+ * What stops the command when the sandbox is not there and --no-sandbox was not given: `why` as the runtime says it
402
+ * (the tools missing on Linux, the system with none, a start that failed), and the way on for the system.
403
+ */
404
+ export function sandboxRefusal(platform, why) {
405
+ if (platform === 'darwin')
406
+ return `Impeccable runs the setup's commands in macOS's sandbox, and it did not start here (${why}). Run impeccable-reviews in a terminal of your own, outside any other sandbox.`;
407
+ const without = 'run it with --no-sandbox, which runs them without one, with all your access';
408
+ if (platform === 'linux')
409
+ return `Impeccable runs the setup's commands in Linux's sandbox, and it did not start here (${why}). Install bubblewrap, socat and ripgrep, or ${without}.`;
410
+ return `Impeccable runs the setup's commands in a sandbox, and there is none for ${platform}. To go on anyway, ${without}.`;
411
+ }
141
412
  /** Runs `true` in the sandbox: undefined when it did, else the first line of why not. */
142
413
  async function selfTest() {
143
414
  const wrapped = await SandboxManager.wrapWithSandbox('true');
@@ -167,22 +438,27 @@ const readText = (path) => {
167
438
  }
168
439
  };
169
440
  /**
170
- * Starts the sandbox for this run, before anything runs: on macOS a sandbox that will not start stops the command,
171
- * and elsewhere its commands run unsandboxed, which `warning` says once, on the first screen. From here on git
172
- * outside the sandbox is pinned to this checkout's folders.
441
+ * The caches and stores of the write list that a command in the sandbox could not make itself, which the run makes
442
+ * before it starts: on macOS those under a folder that is not there, since the sandbox writes the store and not the
443
+ * folder it goes in (pnpm's home, whose global commands are on PATH); on Linux every one not there, which bubblewrap
444
+ * leaves out of the list.
445
+ */
446
+ export function storeFolders(o) {
447
+ const exists = o.exists ?? existsSync;
448
+ const stores = [...CACHES, ...(o.platform === 'darwin' ? MACOS.caches : [])].map((path) => join(o.home, path));
449
+ return stores.filter((path) => !exists(path) && (o.platform !== 'darwin' || !exists(dirname(path))));
450
+ }
451
+ /**
452
+ * Starts the sandbox for this run, before anything runs: a sandbox that is not there or will not start stops the
453
+ * command, unless --no-sandbox was given and this is not macOS, when its commands run unsandboxed, which `warning`
454
+ * says once, on the first screen. From here on git outside the sandbox is pinned to this checkout's folders, and
455
+ * their settings are watched.
173
456
  */
174
457
  export async function startSandbox(o) {
175
- const platform = process.platform;
458
+ const platform = o.platform ?? process.platform;
176
459
  const home = o.home ?? homedir();
177
460
  const git = gitDirsOf(o.root);
178
461
  pinned = git;
179
- // Bubblewrap denies only what is there, so the folder npx keeps its installs in is made before any command runs: a command in the sandbox never gets to make it its own.
180
- try {
181
- mkdirSync(join(home, '.npm', '_npx'), { recursive: true });
182
- }
183
- catch {
184
- // A home that takes no folder takes none from a sandboxed command either.
185
- }
186
462
  const worktrees = (() => {
187
463
  try {
188
464
  return readdirSync(join(git.commonDir, 'worktrees'));
@@ -191,11 +467,12 @@ export async function startSandbox(o) {
191
467
  return [];
192
468
  }
193
469
  })();
194
- const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees });
470
+ const global = scrubbedGitConfig(git.root, home, o.log);
471
+ const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: global.files });
195
472
  const files = checkoutFiles(git.root);
196
473
  const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
197
474
  let domains = allowedDomains({ ...sources, hosts: o.hosts() });
198
- const config = { network: { allowedDomains: domains, deniedDomains: [] }, filesystem: policy };
475
+ const config = { network: { allowedDomains: domains, ...DENIED }, filesystem: policy };
199
476
  // Temp files land in the scratch folder: the sandbox's runtime names the folder its commands get as TMPDIR from this variable.
200
477
  const temp = join(o.scratch, 'tmp');
201
478
  mkdirSync(temp, { recursive: true });
@@ -208,11 +485,19 @@ export async function startSandbox(o) {
208
485
  else if (!SandboxManager.isSupportedPlatform())
209
486
  off = 'no sandbox for this system';
210
487
  else {
211
- const missing = SandboxManager.checkDependencies().errors;
488
+ const missing = (o.missing ?? (() => SandboxManager.checkDependencies().errors))();
212
489
  if (missing.length)
213
490
  off = missing.join(', ');
214
491
  }
215
492
  if (!off) {
493
+ for (const path of storeFolders({ home, platform })) {
494
+ try {
495
+ mkdirSync(path, { recursive: true, mode: 0o700 });
496
+ }
497
+ catch (err) {
498
+ o.log.write('store folder not made', { path, code: err.code });
499
+ }
500
+ }
216
501
  try {
217
502
  // A host off the list is refused by the sandbox's proxy; the debug log names it, which says what a failed install or build wanted.
218
503
  await SandboxManager.initialize(config, async ({ host, port }) => {
@@ -227,21 +512,46 @@ export async function startSandbox(o) {
227
512
  if (off)
228
513
  await SandboxManager.reset().catch(() => { });
229
514
  }
230
- if (off && platform === 'darwin') {
231
- throw new UserError(`Impeccable runs the setup's commands in macOS's sandbox, and it did not start here (${off}). Run impeccable-reviews in a terminal of your own, outside any other sandbox.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
232
- }
515
+ // --no-sandbox goes on without one where the sandbox is not there; macOS always has its own, and stops as before.
516
+ if (off && !(o.noSandbox && platform !== 'darwin'))
517
+ throw new UserError(sandboxRefusal(platform, off), { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
233
518
  o.log.write(off ? `sandbox off: ${off}` : 'sandbox on', { domains, ...policy });
519
+ // Watched from before any command runs: the shell below is the first thing that runs one.
520
+ const watched = watchGit(git, worktrees);
234
521
  const local = createLocalBashOperations();
522
+ // The commands in flight, each with a way to stop it: pi kills the command's whole process tree on an abort.
523
+ const inFlight = new Set();
524
+ const exec = async (command, cwd, options) => {
525
+ const stop = new AbortController();
526
+ inFlight.add(stop);
527
+ try {
528
+ return await local.exec(command, cwd, { ...options, signal: options.signal ? AbortSignal.any([options.signal, stop.signal]) : stop.signal });
529
+ }
530
+ finally {
531
+ inFlight.delete(stop);
532
+ }
533
+ };
534
+ // A run that stops before its summary still leaves none of them behind: on exit, and on a signal, which ends the process with no exit event. The commands in flight are stopped first, so none writes after the removal. The screen, when there is one, sends the signal on after its own stop; without one it is sent on from here.
535
+ process.on('exit', () => void watched.settle());
536
+ for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT']) {
537
+ process.once(signal, () => {
538
+ for (const stop of inFlight)
539
+ stop.abort();
540
+ watched.settle();
541
+ if (!process.listenerCount(signal))
542
+ process.kill(process.pid, signal);
543
+ });
544
+ }
235
545
  let running = 0;
236
546
  const shell = {
237
547
  exec: async (command, cwd, options) => {
238
548
  const place = o.place(command);
239
549
  if (place === 'install')
240
- return local.exec(command, ownFolder(), options);
550
+ return exec(command, ownFolder(), { ...options, env: { ...(options.env ?? process.env), ...installEnvironment() } });
241
551
  if (place === 'person')
242
- return local.exec(command, cwd, { ...options, env: gitEnvironment(options.env) });
552
+ return exec(command, cwd, { ...options, env: gitEnvironment(options.env) });
243
553
  if (off)
244
- return local.exec(command, cwd, options);
554
+ return exec(command, cwd, options);
245
555
  // A host's API joins the list once the person said yes to setting that host up.
246
556
  const next = allowedDomains({ ...sources, hosts: o.hosts() });
247
557
  if (next.join() !== domains.join()) {
@@ -252,7 +562,8 @@ export async function startSandbox(o) {
252
562
  const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
253
563
  running++;
254
564
  try {
255
- return await local.exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE } });
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.
566
+ return await exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE, ...scratchCaches(o.scratch), ...pnpmEnvironment(home, platform), GIT_CONFIG_GLOBAL: global.path } });
256
567
  }
257
568
  finally {
258
569
  // On Linux this takes away the files bubblewrap put in place of absent denied paths: only once no command still runs on them.
@@ -261,15 +572,18 @@ export async function startSandbox(o) {
261
572
  }
262
573
  },
263
574
  };
575
+ const proven = "the preview job on the pull request proves a static site's build instead";
264
576
  return {
265
577
  warning: off && `${off[0].toUpperCase()}${off.slice(1)}, so the setup's commands run without a sandbox, with all your access.`,
578
+ noPreview: off ? `The local preview check, which needs a sandbox of its own, does not run either; ${proven}.` : platform === 'linux' ? `On Linux bubblewrap keeps the app from the local preview check, so the check does not run here; ${proven}.` : undefined,
266
579
  shell,
580
+ finish: () => watched.settle(),
267
581
  preview(folder) {
268
582
  if (off || platform === 'linux')
269
583
  return null;
270
584
  const file = join(settings, `preview-${randomBytes(6).toString('hex')}.json`);
271
585
  const own = {
272
- network: { allowedDomains: domains, deniedDomains: [], allowLocalBinding: true },
586
+ network: { allowedDomains: domains, ...DENIED, allowLocalBinding: true },
273
587
  filesystem: { denyRead: policy.denyRead, allowWrite: [realpathSync(folder)], denyWrite: [] },
274
588
  };
275
589
  writeFileSync(file, JSON.stringify(own), { mode: 0o600 });