@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.
- package/README.md +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +29 -14
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +7 -6
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +3 -3
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +8 -5
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +377 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- 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,
|
|
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
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
...
|
|
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
|
-
/**
|
|
75
|
-
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
550
|
+
return exec(command, ownFolder(), { ...options, env: { ...(options.env ?? process.env), ...installEnvironment() } });
|
|
241
551
|
if (place === 'person')
|
|
242
|
-
return
|
|
552
|
+
return exec(command, cwd, { ...options, env: gitEnvironment(options.env) });
|
|
243
553
|
if (off)
|
|
244
|
-
return
|
|
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
|
-
|
|
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,
|
|
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 });
|