@impeccable/reviews 0.1.1 → 0.1.3

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.
@@ -1,10 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { commandRunner } from './netlify.js';
4
+ import { ownFolder } from './sandbox.js';
4
5
  /**
5
6
  * Cloudflare Pages: the preview deployments this repository's pull requests
6
- * get, from GitHub's record and from wrangler on this machine when it is
7
- * signed in (read only), and whether wrangler is signed in. Cloudflare's
7
+ * get, from GitHub's record and from cf, Cloudflare's CLI, on this machine
8
+ * when it is signed in (read only), and whether cf is signed in. Cloudflare's
8
9
  * GitHub app records no deployment and no commit status: its record is a
9
10
  * check run on each commit it built and a comment on the pull request, both
10
11
  * naming the deployment's address (https://<id>.<project>.pages.dev) and the
@@ -27,7 +28,7 @@ const list = (value) => (Array.isArray(value) ? value.map(record).filter((item)
27
28
  const sha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
28
29
  /** A branch name GitHub can be asked about: the characters git allows, no `..`, and neither a slash nor a dot at the end. */
29
30
  const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
30
- /** A branch, as GitHub names it in a check run's summary or a wrangler row: git's characters, and not too long to be one. */
31
+ /** A branch, as GitHub names it in a check run's summary or cf in a deployment: git's characters, and not too long to be one. */
31
32
  const branchOf = (value) => (typeof value === 'string' && branchName(value) ? value : undefined);
32
33
  /** The project a Pages address belongs to; null for any other address. */
33
34
  export function pagesProject(url) {
@@ -153,57 +154,98 @@ export function plainJson(text) {
153
154
  }
154
155
  return out.replace(/,(\s*[}\]])/g, '$1');
155
156
  }
156
- /** What `wrangler whoami --json` printed, as JSON: the same object signed out, with `loggedIn` false, whatever it exited with. */
157
- const whoamiOf = (printed) => {
158
- const text = String(printed ?? '');
159
- return record(JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)));
160
- };
161
- /** `wrangler whoami --json`: whether wrangler is signed in. */
162
- export async function wranglerStatus(npx) {
157
+ /** cf, Cloudflare's CLI, at the version this command is built against, run through npx, which installs it on first use. */
158
+ export const CF_PACKAGE = 'cf@1.0.0-beta.8';
159
+ /**
160
+ * cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
161
+ * when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
162
+ * checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox: every cf
163
+ * command names its project or its account, so none needs the checkout.
164
+ */
165
+ export function cfRunner(opts = {}, runner = commandRunner) {
166
+ return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', CF_PACKAGE, ...args]);
167
+ }
168
+ /** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
169
+ export function cfFailure(err) {
170
+ const said = String(err.stderr ?? '').replace(/\x1b\[[0-9;]*m/g, '');
171
+ const status = /^│?\s*([1-5]\d\d)(?: [A-Z][A-Za-z ]*)?(?: · HTTP \S*)?\s*$/m.exec(said)?.[1];
172
+ return status ? Number(status) : null;
173
+ }
174
+ /** `cf auth whoami`, which prints JSON signed in or out: whether it is signed in with a token that works, its scopes and its accounts. */
175
+ export async function cfStatus(cf, signal) {
163
176
  let status;
164
177
  try {
165
- status = whoamiOf(await npx(['--yes', 'wrangler', 'whoami', '--json']));
178
+ const printed = await cf(['auth', 'whoami'], { signal });
179
+ status = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
166
180
  }
167
181
  catch (err) {
168
- if (err.missing)
169
- return 'missing';
170
- try {
171
- status = whoamiOf(err.stdout);
172
- }
173
- catch {
174
- return 'unknown';
175
- }
182
+ return { cli: err.missing ? 'missing' : 'unknown', accounts: [] };
176
183
  }
177
- return status?.loggedIn === true ? 'signed-in' : status?.loggedIn === false ? 'signed-out' : 'unknown';
184
+ // A stored sign-in that neither the user's record nor the accounts answer is one to sign in again.
185
+ if (status?.authenticated === false || (status?.authenticated === true && status.tokenValid === false))
186
+ return { cli: 'signed-out', accounts: [] };
187
+ if (status?.authenticated !== true)
188
+ return { cli: 'unknown', accounts: [] };
189
+ const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
190
+ const accounts = list(status.accounts).map((a) => a.id).filter((id) => typeof id === 'string' && /^[0-9a-f]{32}$/.test(id));
191
+ return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts };
178
192
  }
193
+ /** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
194
+ const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
195
+ /** A page of cf's list of deployments, and how many it reads of each account at most: the newest pull requests' builds come first. */
196
+ const PER_PAGE = 25;
197
+ const PAGES = 4;
179
198
  /**
180
- * What wrangler lists of the project's preview deployments when it is signed in: `pages deployment list --json` prints
181
- * one row a deployment, with the branch, the commit's first seven characters, the address and the status (a date once
182
- * the build succeeded, else the stage's word).
199
+ * What cf lists of the project's preview deployments when it is signed in with Pages access: `pages deployments list`
200
+ * prints them as Cloudflare's API has them, each with its address, the branch's alias, the branch and commit it built,
201
+ * its latest stage and the repository it was built from. Each account the sign-in reaches is read, since another may hold
202
+ * a project of the same name, a page at a time, until the list ends or every head has its build; a deployment Cloudflare
203
+ * built from another repository is not this one's.
183
204
  */
184
- async function fromWrangler(project, npx) {
185
- const cli = await wranglerStatus(npx);
186
- if (cli !== 'signed-in' || !project)
205
+ async function fromCf(project, cf, repository, signal) {
206
+ const { cli, scopes, accounts } = await cfStatus(cf, signal);
207
+ const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
208
+ if (cli !== 'signed-in' || !project || !pages)
187
209
  return { cli, rows: [], failed: cli === 'unknown' };
188
- try {
189
- const printed = await npx(['--yes', 'wrangler', 'pages', 'deployment', 'list', '--project-name', project, '--environment', 'preview', '--json']);
190
- const rows = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1))).flatMap((row, at) => {
191
- const url = typeof row.Deployment === 'string' && PAGES_ADDRESS.test(row.Deployment) ? row.Deployment : undefined;
192
- if (!url || !/^preview$/i.test(String(row.Environment)))
193
- return [];
194
- const status = String(row.Status ?? '');
195
- const state = /^(idle|active|queued|building)$/i.test(status) ? 'building' : /^fail/i.test(status) ? 'failure' : /^cancel/i.test(status) ? 'canceled' : 'success';
196
- const commit = typeof row.Source === 'string' && /^[0-9a-f]{7,40}$/.test(row.Source) ? row.Source : undefined;
197
- return [{ ...(branchOf(row.Branch) ? { branch: row.Branch } : {}), ...(commit ? { sha: commit } : {}), url, state, from: 'wrangler', at: -at }];
198
- });
199
- return { cli, rows, failed: false };
200
- }
201
- catch {
202
- return { cli, rows: [], failed: true };
210
+ const same = (value, name) => typeof value === 'string' && value.toLowerCase() === name.toLowerCase();
211
+ const ours = (d) => {
212
+ const source = record(record(d.source)?.config);
213
+ return !source || (same(source.owner, repository.owner) && same(source.repo_name, repository.repo));
214
+ };
215
+ const rows = [];
216
+ const built = () => repository.heads.every((sha) => rows.some((row) => row.sha && sha.startsWith(row.sha)));
217
+ let failed = false;
218
+ for (const account of accounts.length ? accounts : [undefined]) {
219
+ if (rows.length && built())
220
+ break;
221
+ for (let page = 1; page <= PAGES; page++) {
222
+ let listed;
223
+ try {
224
+ const printed = await cf(['pages', 'deployments', 'list', '--project-name', project, '--env', 'preview', '--page', String(page), '--per-page', String(PER_PAGE)], { account, signal });
225
+ listed = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1)));
226
+ }
227
+ catch (err) {
228
+ // An account with no such project answers 404; one that could not be read leaves the others' builds standing.
229
+ failed ||= cfFailure(err) !== 404;
230
+ break;
231
+ }
232
+ for (const d of listed.filter(ours)) {
233
+ const url = typeof d.url === 'string' && PAGES_ADDRESS.test(d.url) ? d.url : undefined;
234
+ if (!url || d.environment !== 'preview')
235
+ continue;
236
+ const trigger = record(record(d.deployment_trigger)?.metadata);
237
+ const alias = (Array.isArray(d.aliases) ? d.aliases : []).find((a) => typeof a === 'string' && PAGES_ADDRESS.test(a));
238
+ const commit = typeof trigger?.commit_hash === 'string' && /^[0-9a-f]{7,40}$/.test(trigger.commit_hash) ? trigger.commit_hash : undefined;
239
+ rows.push({ ...(branchOf(trigger?.branch) ? { branch: trigger.branch } : {}), ...(commit ? { sha: commit } : {}), url, ...(alias ? { alias } : {}), state: stageState(record(d.latest_stage)), from: 'cf', at: -rows.length });
240
+ }
241
+ if (listed.length < PER_PAGE || built())
242
+ break;
243
+ }
203
244
  }
245
+ return { cli, rows, failed };
204
246
  }
205
247
  /**
206
- * Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and wrangler have them;
248
+ * Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
207
249
  * with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
208
250
  */
209
251
  export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
@@ -214,11 +256,11 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
214
256
  return unread();
215
257
  const github = await fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull, deps.branch);
216
258
  const project = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (await configuredProject(cwd));
217
- // wrangler installs itself through npx on a machine without it, which takes a while the first time.
218
- const wrangler = await fromWrangler(project, deps.npx ?? commandRunner('npx', cwd, { signal: deps.signal, timeoutMs: 120_000 }));
259
+ // cf installs itself through npx on a machine without it, which takes a while the first time.
260
+ const cf = await fromCf(project, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha) }, deps.signal);
219
261
  // A row is the head's when its commit is the head's; the pull request's heads say which. A wait for a pushed branch takes its head's rows alone.
220
262
  const head = (row) => github.heads.find((h) => row.sha && h.sha.startsWith(row.sha) && (deps.branch === undefined || h.branch === deps.branch));
221
- const rows = wrangler.rows.flatMap((row) => {
263
+ const rows = cf.rows.flatMap((row) => {
222
264
  const of = head(row);
223
265
  if (deps.branch !== undefined && !of)
224
266
  return [];
@@ -226,8 +268,8 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
226
268
  return [];
227
269
  return [of ? { ...row, ...(of.number ? { pullRequest: of.number } : {}), branch: of.branch ?? row.branch, sha: of.sha, at: of.at } : row];
228
270
  });
229
- // Of the same build, the check run outranks wrangler's row, and both the app's comment; each address once.
230
- const rank = (p) => (p.from === 'check run' ? 0 : p.from === 'wrangler' ? 1 : 2);
271
+ // Of the same build, the check run outranks cf's row, and both the app's comment; each address once.
272
+ const rank = (p) => (p.from === 'check run' ? 0 : p.from === 'cf' ? 1 : 2);
231
273
  const previews = [];
232
274
  for (const p of [...github.previews, ...rows].sort((a, b) => rank(a) - rank(b))) {
233
275
  const twin = previews.find((q) => q.url === p.url && (q.pullRequest ?? q.branch) === (p.pullRequest ?? p.branch));
@@ -236,14 +278,14 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
236
278
  else if (!twin.alias && p.alias)
237
279
  twin.alias = p.alias;
238
280
  }
239
- // The most recently updated pull request's first; a branch with no pull request among them last, as wrangler lists it.
281
+ // The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
240
282
  previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
241
283
  return {
242
284
  ...(project ? { project } : {}),
243
285
  previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
244
- cli: wrangler.cli,
245
- ...(github.failed || wrangler.failed
246
- ? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(wrangler.failed ? ['wrangler'] : [])] }
286
+ cli: cf.cli,
287
+ ...(github.failed || cf.failed
288
+ ? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(cf.failed ? ['cf'] : [])] }
247
289
  : {}),
248
290
  };
249
291
  }
package/dist/fence.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { gitEnvironment } from './sandbox.js';
4
5
  import { DATA_NOTE, scan, scanRecorder } from './scan.js';
5
6
  const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
6
7
  const deny = (reason) => ({ verdict: 'deny', reason });
@@ -329,7 +330,7 @@ function scriptsIn(dir) {
329
330
  * package.json and the files the scripts and listed tools load from the checkout by convention (`*.config.js`, `.*rc`): once the agent
330
331
  * changes one, what runs is no longer the repository's own code.
331
332
  */
332
- /** The ci-tunnel case corrects Vite's preview.allowedHosts and proves it with the local preview check, which runs in its own copy and environment: after that edit only the shell stops. */
333
+ /** A rerun on Impeccable's tunnel (first-preview-failure) corrects Vite's preview.allowedHosts and proves it with the local preview check, which runs in its own copy and environment: after that edit only the shell stops. */
333
334
  const VITE_CONFIG = /(?:^|\/)vite\.config\.[cm]?[jt]s$/i;
334
335
  const LOADED = /(?:^|\/)(?:package\.json|[\w.-]+\.config\.[cm]?[jt]s|\.[\w-]+rc(?:\.\w+)*|tsconfig[^/]*\.json|knexfile\.[^/]+|bunfig\.toml|prisma\/seed\.[^/]+|node_modules\/.+)$/i;
335
336
  /** Why scripts and tools stop running: the agent changed a package.json (true when it does not say which), or a file named here that they load. */
@@ -354,6 +355,8 @@ function tool(args, where, scriptsChanged = false) {
354
355
  // A version or a tag may follow the name; an alias such as next@npm:other is another package, so it stays on.
355
356
  const name = (args[i] ?? '').replace(/@[\w.^~<>=*+-]+$/, '');
356
357
  const rest = args.slice(i + 1);
358
+ if (name === 'cf')
359
+ return cloudflareCli(rest);
357
360
  if (!TOOLS.has(name))
358
361
  return deny(`${name || 'that'} is not on the tool list`);
359
362
  if (name === 'playwright' && (rest[0] === 'install-deps' || rest.includes('--with-deps')))
@@ -364,6 +367,15 @@ function tool(args, where, scriptsChanged = false) {
364
367
  return deny(changedReason(scriptsChanged));
365
368
  return inside(rest, where) ? allow() : deny('a path outside the checkout');
366
369
  }
370
+ /**
371
+ * cf, Cloudflare's CLI: the tools run it in the command's own process, as the person (its sign-in, the preview
372
+ * deployments, and the service token, whose secret it prints), so none of it runs here, and a delete never does.
373
+ */
374
+ function cloudflareCli(args) {
375
+ if (args.some((a) => a === 'delete' || a === 'disconnect'))
376
+ return never('deletes on Cloudflare');
377
+ return deny("cf runs in the tools, as the person: impeccable_cloudflare_previews reads Cloudflare's previews, and impeccable_cloudflare_access signs in and makes the service token");
378
+ }
367
379
  /** A package manager's install, script or version; with `tools`, also a tool from the list through pnpm exec or yarn. */
368
380
  function packageManager(pm, args, where, scriptsChanged, tools = true) {
369
381
  const [sub = '', ...rest] = args;
@@ -417,6 +429,25 @@ function reader(cmd, args, where) {
417
429
  }
418
430
  /** A host's CLI package, by host: the one global install that runs, and only once the person said yes to setting that host up. */
419
431
  const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
432
+ /** git commit and push, gh and the host CLIs: what uses the person's GitHub, Vercel, Netlify or Cloudflare sign-in, keys or keychain. */
433
+ function asPerson(words) {
434
+ const [cmd = '', ...args] = words;
435
+ if (cmd === 'git')
436
+ return ['commit', 'push'].includes((args[0] === '--no-pager' ? args[1] : args[0]) ?? '');
437
+ return cmd === 'gh' || cmd === 'vercel' || cmd === 'netlify' || cmd === 'cf';
438
+ }
439
+ /** Where the command runs; null when it joins what runs as the person to what runs in the sandbox, which the fence refuses. cd and git's other commands, which need no sign-in, run on either side. */
440
+ export function placeOf(command) {
441
+ const commands = (parseCommand(command) ?? []).map(({ words }) => words.slice(envCount(words)));
442
+ const installs = commands.filter(([pm = '', ...args]) => PACKAGE_MANAGERS.has(pm) && installsHostCli(pm, args, new Set(Object.keys(HOST_CLIS)))).length;
443
+ if (installs)
444
+ return installs === commands.length ? 'install' : null;
445
+ const person = commands.filter(asPerson).length;
446
+ if (!person)
447
+ return 'sandbox';
448
+ const either = commands.filter((words) => !asPerson(words) && (words[0] === 'cd' || words[0] === 'git')).length;
449
+ return person + either === commands.length ? 'person' : null;
450
+ }
420
451
  /** Whether this is a global install of a host's CLI and nothing else, the person having said yes to setting that host up. */
421
452
  function installsHostCli(pm, args, hosts) {
422
453
  const rest = args.filter((a) => !['-g', '--global', '--location=global'].includes(a));
@@ -427,14 +458,13 @@ function installsHostCli(pm, args, hosts) {
427
458
  return global && (verbs[pm] ?? []).includes(verb) && !more.length && Object.entries(HOST_CLIS).some(([host, pkg]) => pkg === cli && hosts.has(host));
428
459
  }
429
460
  /** The Netlify CLI's options that take a value, of those the rules below allow, so the value is not read as a command. */
430
- const NETLIFY_VALUES = new Set(['--name', '--context', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
431
- /** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, the one deploy context, a team's slug. */
461
+ const NETLIFY_VALUES = new Set(['--name', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
462
+ /** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, a team's slug. */
432
463
  const NETLIFY_VALID = {
433
464
  '--name': /^[A-Za-z0-9-]{1,63}$/,
434
465
  '--id': /^[0-9a-f-]{8,64}$/i,
435
466
  '--git-remote-url': /^(?:https:\/\/github\.com\/)?[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?(?:\.git)?$/,
436
467
  '--git-remote-name': /^[A-Za-z0-9._-]{1,50}$/,
437
- '--context': /^deploy-preview$/,
438
468
  '--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
439
469
  };
440
470
  /** The Vercel project the folder is linked to, as `vercel link` wrote it in `.vercel/project.json`; null with none. */
@@ -447,25 +477,15 @@ function linkedVercelProject(dir) {
447
477
  return null;
448
478
  }
449
479
  }
450
- /** Whether the folder is linked to a Netlify site: `.netlify/state.json` names it. */
451
- function linkedToSite(dir) {
452
- try {
453
- return typeof JSON.parse(readFileSync(join(dir, '.netlify', 'state.json'), 'utf8')).siteId === 'string';
454
- }
455
- catch {
456
- return false;
457
- }
458
- }
459
480
  /**
460
481
  * The Netlify CLI: `--version` and `status` at any time; once the person signed in to Netlify for the setup, the
461
- * project's link and creation, in the person's own current team or the one team they chose (`team`), and a built draft
462
- * deploy for a deploy preview of a linked folder. The sign-in and the repository's connection are the tools' (a
463
- * `netlify login` outside the sign-in screen, and a `netlify init` that asks its questions in a terminal, never run).
464
- * Never production or a project's removal, and nothing of its environment variables, its API (which reaches them, and
465
- * prints a site's password: impeccable_netlify_password runs it in the command's own process) or a token on the
466
- * command line.
482
+ * project's link and creation, in the person's own current team or the one team they chose (`team`). The sign-in and
483
+ * the repository's connection are the tools' (a `netlify login` outside the sign-in screen, and a `netlify init` that
484
+ * asks its questions in a terminal, never run). Never a deploy, which builds the repository with the Netlify sign-in,
485
+ * production or a project's removal, and nothing of its environment variables, its API (which reaches them, and prints
486
+ * a site's password: impeccable_netlify_password runs it in the command's own process) or a token on the command line.
467
487
  */
468
- function netlify(args, where, agreed, team) {
488
+ function netlify(args, agreed, team) {
469
489
  const words = [];
470
490
  const options = new Map();
471
491
  for (let i = 0; i < args.length; i++) {
@@ -509,10 +529,8 @@ function netlify(args, where, agreed, team) {
509
529
  case 'sites:create':
510
530
  return !rest.length && only('--name', '--json', '--account-slug') ? allow() : deny('a netlify sites:create off the allowlist');
511
531
  case 'deploy':
512
- if (rest.length || !options.has('--build') || !options.has('--context') || !only('--build', '--context', '--json'))
513
- return deny('a netlify deploy other than a built draft for the deploy-preview context');
514
- // Where no site is linked the CLI creates one with a random name, which is not what the person said yes to.
515
- return linkedToSite(where.cwd) ? allow() : deny('a netlify deploy in a folder linked to no site');
532
+ // The build runs in the sandbox and the Netlify CLI outside it, as the person: never both at once.
533
+ return deny("netlify deploy builds the repository with the person's Netlify sign-in, and the two never run together");
516
534
  default:
517
535
  return deny(sub.startsWith('env') ? "Netlify's environment variables are the person's" : `netlify ${sub || 'without a command'} is not on the allowlist`);
518
536
  }
@@ -521,16 +539,8 @@ function netlify(args, where, agreed, team) {
521
539
  const VERCEL_VALUES = new Set(['--cwd', '--target', '--scope', '-S', '--team']);
522
540
  /** The options that name a team: the CLI never picks one without a terminal, so the run names the one the person chose. */
523
541
  const VERCEL_TEAM = ['--scope', '-S', '--team'];
524
- /**
525
- * The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
526
- * project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
527
- * the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
528
- * `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
529
- * environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
530
- * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
531
- * line.
532
- */
533
- function vercel(args, where, agreed, team, settlement = null) {
542
+ /** The Vercel CLI's arguments as its words, the subcommand first, and its options, each with the value it takes. */
543
+ function vercelArgs(args) {
534
544
  const words = [];
535
545
  const options = new Map();
536
546
  for (let i = 0; i < args.length; i++) {
@@ -545,6 +555,19 @@ function vercel(args, where, agreed, team, settlement = null) {
545
555
  const value = at > 0 ? arg.slice(at + 1) : VERCEL_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
546
556
  options.set(name, value);
547
557
  }
558
+ return { words, options };
559
+ }
560
+ /**
561
+ * The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
562
+ * project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
563
+ * the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
564
+ * `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
565
+ * environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
566
+ * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
567
+ * line.
568
+ */
569
+ function vercel(args, where, agreed, team, settlement = null) {
570
+ const { words, options } = vercelArgs(args);
548
571
  const [sub = '', ...rest] = words;
549
572
  if ([...options.keys()].some((name) => name === '--token' || /^-t/.test(name)))
550
573
  return deny('puts a Vercel token on the command line');
@@ -662,9 +685,11 @@ function simpleCommand(seg, where, opts) {
662
685
  case 'find':
663
686
  return reader(cmd, args, where);
664
687
  case 'netlify':
665
- return netlify(args, where, opts.hosts.has('netlify'), opts.teams.get('netlify'));
688
+ return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'));
666
689
  case 'vercel':
667
690
  return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
691
+ case 'cf':
692
+ return cloudflareCli(args);
668
693
  default:
669
694
  if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
670
695
  return allow('installs the CLI of the host the person said yes to');
@@ -692,7 +717,7 @@ function readable(path, where) {
692
717
  function commitMessages(revisions, cwd) {
693
718
  try {
694
719
  // The closing -- reads each as a commit even where a file of the same name is there.
695
- return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
720
+ return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024, env: gitEnvironment() });
696
721
  }
697
722
  catch {
698
723
  return null;
@@ -778,6 +803,9 @@ export function classifyCommand(command, root, options = {}) {
778
803
  if (seg.words[0] === 'cd' && decision.verdict === 'allow')
779
804
  where.cwd = resolve(where.cwd, seg.words[1]);
780
805
  }
806
+ if (result.verdict === 'allow' && placeOf(command) === null) {
807
+ return deny("joins git commit or push, gh, vercel, netlify or a host CLI's install, which run outside the sandbox as the person, to what runs in it: run them as separate commands");
808
+ }
781
809
  return result;
782
810
  }
783
811
  /**
@@ -871,14 +899,16 @@ export function classifyPreviewCommand(command, root, options = {}) {
871
899
  }
872
900
  return allow('inside the disposable preview verifier');
873
901
  }
874
- /** The fence's answer for pi's file tools: writes stay in the checkout and out of .git; reads stay in it or in the run's scratch folder, and off .env files. */
902
+ /** The fence's answer for pi's file tools: writes stay in the checkout and out of every .git, the checkout's and any other a file could make; reads stay in it or in the run's scratch folder, and off .env files. */
875
903
  export function classifyFileTool(tool, path, root, scratch) {
876
904
  const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
877
905
  const p = typeof path === 'string' && path ? path : '.';
878
906
  if (tool === 'write' || tool === 'edit') {
879
907
  if (outside(p, where))
880
908
  return never('writes outside the checkout');
881
- return within(realPath(resolve(root, p)), join(where.root, '.git')) ? never('writes into .git, where hooks run') : allow();
909
+ // A .git file or folder below the top would be where git takes its settings and hooks from there.
910
+ const inGit = [p, relative(where.root, realPath(resolve(root, p)))].some((q) => q.split(/[\\/]/).includes('.git'));
911
+ return inGit ? never('writes into .git, where hooks run') : allow();
882
912
  }
883
913
  if (outside(p, where) && !inScratch(p, where))
884
914
  return deny('reads outside the checkout');
@@ -1087,7 +1117,7 @@ export function observePreviewAttempts(state, runs, historyComplete) {
1087
1117
  }
1088
1118
  /** The hosts the person said yes to setting up in this run, by the run's log. */
1089
1119
  const agreedHosts = new WeakMap();
1090
- function hostsAgreed(log) {
1120
+ export function hostsAgreed(log) {
1091
1121
  let hosts = agreedHosts.get(log);
1092
1122
  if (!hosts)
1093
1123
  agreedHosts.set(log, (hosts = new Set()));
@@ -1103,6 +1133,42 @@ export function agreeToHostSetup(log, questionId) {
1103
1133
  export function hostSetupAgreed(log, host) {
1104
1134
  return hostsAgreed(log).has(host);
1105
1135
  }
1136
+ /** The runs whose `vercel link` left its folder linked to a project: that project may build the next push, so its hold stands until it is settled, whatever ends the setup. */
1137
+ const linkedVercel = new WeakSet();
1138
+ /**
1139
+ * The host's setup ended before any project of this run's could build a push: the sign-in skipped or not completed, the
1140
+ * team skipped, or the link failed. Its CLI stops and the hold on pushes goes, as though the host was never offered; a
1141
+ * Vercel project linked in this run keeps its hold until it is settled.
1142
+ */
1143
+ export function endHostSetup(log, host) {
1144
+ if (host !== 'vercel' || !linkedVercel.has(log))
1145
+ hostsAgreed(log).delete(host);
1146
+ }
1147
+ /**
1148
+ * What the `vercel link`s of a command that ran left, each in the folder it ran in (the one `--cwd` names, else the
1149
+ * shell's, after its `cd`), whatever the command's exit: a folder linked to a project keeps the hold; links that left
1150
+ * their folders linked to none failed, and end the Vercel setup.
1151
+ */
1152
+ function noteVercelLinks(log, root, command) {
1153
+ let cwd = root;
1154
+ const folders = [];
1155
+ for (const { words } of parseCommand(command) ?? []) {
1156
+ if (words[0] === 'cd')
1157
+ cwd = resolve(cwd, words[1] ?? '.');
1158
+ if (words[0] !== 'vercel')
1159
+ continue;
1160
+ const { words: [sub], options } = vercelArgs(words.slice(1));
1161
+ const folder = options.get('--cwd');
1162
+ if (sub === 'link')
1163
+ folders.push(resolve(cwd, typeof folder === 'string' ? folder : '.'));
1164
+ }
1165
+ if (!folders.length)
1166
+ return;
1167
+ if (folders.some((folder) => linkedVercelProject(folder) !== null))
1168
+ linkedVercel.add(log);
1169
+ else
1170
+ endHostSetup(log, 'vercel');
1171
+ }
1106
1172
  /** The team the person picked for each host's CLI in this run, by the run's log. */
1107
1173
  const chosenTeams = new WeakMap();
1108
1174
  function teamsChosen(log) {
@@ -1254,6 +1320,8 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
1254
1320
  if (!['read', 'grep', 'bash'].includes(event.toolName))
1255
1321
  return undefined;
1256
1322
  const input = event.input;
1323
+ if (event.toolName === 'bash')
1324
+ noteVercelLinks(log, root, String(input.command ?? ''));
1257
1325
  const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
1258
1326
  const hits = scan('input', event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'), sources);
1259
1327
  if (!hits.length)
@@ -81,22 +81,23 @@ export const HOST_SCREENS = {
81
81
  prompt: `Netlify keeps your previews behind the site password, and the run could not read it: the Netlify CLI here is signed out, the site is in none of your teams, or Netlify does not give the password back. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
82
82
  hint: 'Paste the password',
83
83
  },
84
- // Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person, the API token that takes (wrangler's sign-in cannot reach Access), and the paste where the run does not create one.
84
+ // Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person through cf, the retry of cf's sign-in, the API token where that sign-in cannot create it, and the paste where the run does not create one.
85
85
  'cloudflare-access': {
86
86
  id: 'cloudflare-access',
87
87
  kind: 'confirm',
88
88
  title: 'Your Cloudflare previews are protected',
89
- hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
89
+ hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
90
90
  prompt: 'Create the token for Impeccable?',
91
91
  confirmLabel: 'Create',
92
92
  skipLabel: 'Skip',
93
93
  },
94
+ 'cloudflare-signin-retry': { id: 'cloudflare-signin-retry', kind: 'confirm', title: 'Sign in to Cloudflare', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
94
95
  'cloudflare-access-api': {
95
96
  id: 'cloudflare-access-api',
96
97
  kind: 'text',
97
98
  sensitive: true,
98
99
  title: 'One Cloudflare API token',
99
- prompt: 'Creating the token takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
100
+ prompt: 'The Cloudflare CLI here could not create the token, so creating it takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
100
101
  hint: 'Paste the API token',
101
102
  },
102
103
  'cloudflare-access-key': {
package/dist/hosted.js CHANGED
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
4
4
  import { basicCredentials } from './netlify-password.js';
5
+ import { gitEnvironment } from './sandbox.js';
5
6
  import { BYPASS_HEADER } from './vercel-bypass.js';
6
7
  /**
7
8
  * Previews a host already deploys: what GitHub has on record of the
@@ -124,15 +125,33 @@ function plain(text) {
124
125
  /** Whether a file the checkout tracks holds the words; .env files are never read. */
125
126
  async function inCheckout(root, words) {
126
127
  try {
127
- await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000 });
128
+ await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
128
129
  return true;
129
130
  }
130
131
  catch {
131
132
  return false;
132
133
  }
133
134
  }
135
+ /** What a visitor never sees of a page: comments, scripts, styles and templates. */
136
+ const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
134
137
  /** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
135
- const visible = (html) => plain(html.replace(/<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ').replace(/<[^>]*>/g, ' '));
138
+ const visible = (html) => plain(html.replace(UNSEEN, ' ').replace(/<[^>]*>/g, ' '));
139
+ /**
140
+ * Netlify's own lock, never the status alone, as Netlify documents it: the basic challenge (`WWW-Authenticate: Basic`)
141
+ * its Basic-Auth rules send, which "trigger the built-in basic browser authentication"
142
+ * (https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/); the
143
+ * "generic password prompt" of its Password Protection, where a visitor enters a shared password and nothing else
144
+ * (https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection/); or the "Netlify-branded page"
145
+ * its team login shows a visitor (https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility/),
146
+ * whose title names Netlify other than in the site's own address: a mention in an app's own page, such as a footer, is
147
+ * not one.
148
+ */
149
+ function netlifyLock(challenge, html) {
150
+ const seen = html.replace(UNSEEN, ' ');
151
+ const fields = [...seen.matchAll(/<input\b[^>]*>/gi)].map(([tag]) => /\stype\s*=\s*["']?([\w-]+)/i.exec(tag)?.[1]?.toLowerCase() ?? 'text').filter((type) => !['hidden', 'submit', 'button', 'reset', 'image'].includes(type));
152
+ const title = plain(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(seen)?.[1] ?? '');
153
+ return /^basic\b/i.test(challenge ?? '') || (/<form\b/i.test(seen) && fields.join() === 'password') || /\bnetlify\b/.test(title.replace(/\S*\.netlify\.app\S*/g, ' '));
154
+ }
136
155
  /** The first `max` bytes of a page, as text. */
137
156
  async function pageText(res, max = 1_000_000) {
138
157
  const reader = res.body?.getReader();
@@ -181,8 +200,10 @@ async function formOpens(target, password, phrase, open, signal) {
181
200
  * `words` that a file of the checkout at `root` holds, so the page is this app's and not another site's. With `bypass`, a
182
201
  * Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
183
202
  * `password`, a Netlify site password, a Netlify address is opened with it as basic credentials in the Authorization
184
- * header, the one way reviews can send it; with `access`, a Cloudflare Access service token, a Pages address is opened
185
- * with it in the two headers Access reads it from. None goes to another host.
203
+ * header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
204
+ * the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
205
+ * Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
206
+ * to another host.
186
207
  */
187
208
  export async function provePreview(url, words, root, deps = {}) {
188
209
  const unproven = (reason, status = null) => ({ proven: false, status, reason });
@@ -206,7 +227,7 @@ export async function provePreview(url, words, root, deps = {}) {
206
227
  ...(withPassword ? { authorization: basicCredentials(deps.password) } : {}),
207
228
  ...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
208
229
  };
209
- // Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it, or, on Netlify, the app's own sign-in answered past the lock: the address alone cannot say which.
230
+ // Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
210
231
  const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
211
232
  let target = new URL(url);
212
233
  try {
@@ -225,14 +246,14 @@ export async function provePreview(url, words, root, deps = {}) {
225
246
  }
226
247
  if (res.status === 401 || (host !== 'vercel' && res.status === 403)) {
227
248
  // Vercel's own page names Vercel; any other 401 there is the app's own sign-in, which a page a visitor opens without signing in gets past.
228
- // A password or a login in front of a Netlify preview answers 401 or 403 on every page of it, and its page names nothing to go by.
249
+ // Netlify's lock shows itself; the app's own 401 or 403 does not, so it gets no site password.
229
250
  // Cloudflare Access names itself on the 401 or 403 it answers a request it will not let in (a service token it does not admit), where a browser gets its login.
230
251
  const page = await pageText(res, 64_000);
231
- const named = host === 'netlify' || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
252
+ const named = (host === 'netlify' && netlifyLock(res.headers.get('www-authenticate'), page)) || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
232
253
  if (!named)
233
254
  return unproven(res.status === 401 ? "The address asked for the app's own sign-in: try a page a visitor opens without signing in." : `The address answered ${res.status}.`, res.status);
234
255
  // A password the header did not get past: Netlify's own form says whether it is the site's password at all.
235
- if (withPassword && (await formOpens(target, deps.password, phrase, open, signal))) {
256
+ if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
236
257
  return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
237
258
  }
238
259
  return { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate };