@impeccable/reviews 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,7 +52,7 @@ The sign-in's setup token lasts an hour. A run that goes longer asks you to sign
52
52
 
53
53
  The agent's commands run in an operating-system sandbox, macOS's seatbelt or Linux's bubblewrap, through Anthropic's sandbox runtime (`@anthropic-ai/sandbox-runtime`), which holds even when the repository talks the agent into something:
54
54
 
55
- - **Reads**: what your account reads, but your credential folders: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.config/gh`, and your keychains on macOS; the Vercel, Netlify and Cloudflare CLIs' sign-ins (`~/Library/Application Support/com.vercel.cli`, `~/Library/Preferences/netlify` and `~/Library/Preferences/.wrangler` on macOS, `~/.local/share/com.vercel.cli`, `~/.config/netlify` and `~/.config/.wrangler` elsewhere, and `~/.vercel`, `~/.netlify` and `~/.wrangler`); the registries' and build tools' tokens (`~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials`, `~/.gem/credentials`, `~/.m2/settings.xml`, `~/.gradle/gradle.properties`, `~/.terraform.d`); Codex's and Claude's (`~/.codex`, `~/.claude`); your git's global settings (`~/.gitconfig`, `~/.config/git/config` and every file they include, whether the include holds for this checkout or not) and `~/.config/git/credentials`; your shells' rc files (`~/.bashrc`, `~/.bash_profile`, `~/.profile`, `~/.zshenv`, `~/.zshrc`, `~/.zprofile` and the rest, and fish's `~/.config/fish`) and their histories; and your browsers' profiles (Chrome's, Chromium's, Brave's, Edge's and Firefox's, and `~/Library/Cookies` on macOS). Where your environment moves one of these, the sandbox denies the place it names instead: the folder `GH_CONFIG_DIR`, `VERCEL_AUTH_CONFIG_DIR`, `GNUPGHOME`, `DOCKER_CONFIG`, `CLOUDSDK_CONFIG`, `AZURE_CONFIG_DIR`, `CODEX_HOME` or `CLAUDE_CONFIG_DIR` names, the files `KUBECONFIG`, `GIT_CONFIG_GLOBAL` and `HISTFILE` name, cargo's credentials under `CARGO_HOME`, zsh's rc files under `ZDOTDIR`, and the same entries under `XDG_CONFIG_HOME` and `XDG_DATA_HOME` as under `~/.config` and `~/.local/share`; the variables themselves stay, since `gh`, the host CLIs and the package managers read them. Git in the sandbox reads a copy of your global settings instead of the file: your name and email, what shapes how git reads the checkout (line endings, case, excludes, attributes, LFS's content filter) and `safe.directory`, and none of http's headers, proxy, cookies and certificates, url rewrites, credential helpers, includes, aliases or programs; another content filter's command is left out, since it could carry a password, and when that filter is required, git in the sandbox refuses the file it would have changed rather than stage it unfiltered. Your `~/.npmrc` stays readable: the sandbox's hosts come from the registries it names, and npm needs its token to install from them.
55
+ - **Reads**: what your account reads, but your credential folders: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.config/gh`, and your keychains on macOS; the Vercel, Netlify and Cloudflare CLIs' sign-ins (`~/Library/Application Support/com.vercel.cli`, `~/Library/Preferences/netlify` and `~/Library/Preferences/.wrangler` on macOS, `~/.local/share/com.vercel.cli`, `~/.config/netlify` and `~/.config/.wrangler` elsewhere, and `~/.vercel`, `~/.netlify` and `~/.wrangler`); the registries' and build tools' tokens (`~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials`, `~/.gem/credentials`, `~/.m2/settings.xml`, `~/.gradle/gradle.properties`, `~/.terraform.d`); Codex's and Claude's (`~/.codex`, `~/.claude`); your git's global settings (`~/.gitconfig`, `~/.config/git/config` and every file they include, whether the include holds for this checkout or not) and `~/.config/git/credentials`; your shells' rc files (`~/.bashrc`, `~/.bash_profile`, `~/.profile`, `~/.zshenv`, `~/.zshrc`, `~/.zprofile` and the rest, and fish's `~/.config/fish`) and their histories; and your browsers' profiles (Chrome's, Chromium's, Brave's, Edge's and Firefox's, and `~/Library/Cookies` on macOS). Where your environment moves one of these, the sandbox denies the place it names instead: the folder `GH_CONFIG_DIR`, `VERCEL_AUTH_CONFIG_DIR`, `GNUPGHOME`, `DOCKER_CONFIG`, `CLOUDSDK_CONFIG`, `AZURE_CONFIG_DIR`, `CODEX_HOME` or `CLAUDE_CONFIG_DIR` names, the files `KUBECONFIG`, `GIT_CONFIG_GLOBAL` and `HISTFILE` name, cargo's credentials under `CARGO_HOME`, zsh's rc files under `ZDOTDIR`, and the same entries under `XDG_CONFIG_HOME` and `XDG_DATA_HOME` as under `~/.config` and `~/.local/share`; the variables themselves stay, since `gh`, the host CLIs and the package managers read them. Git in the sandbox reads a copy of your global settings instead of the file: your name and email, what shapes how git reads the checkout (line endings, case, excludes, attributes, LFS's content filter) and `safe.directory`, and none of http's headers, proxy, cookies and certificates, url rewrites, credential helpers, includes, aliases or programs; another content filter's command is left out, since it could carry a password, and when that filter is required, git in the sandbox refuses the file it would have changed rather than stage it unfiltered. Git's system settings are scrubbed into the same copy and otherwise turned off for it (`GIT_CONFIG_NOSYSTEM`): on a Mac, Apple's git carries a config inside Xcode and the Command Line Tools that names the keychain's credential helper, and git in the sandbox is to have no credential helper at all, so that it asks for no sign-in rather than fail in one; the helper programs that config names (`git-credential-osxkeychain`, in Xcode and in the Command Line Tools) are denied too, so a script that puts the variable back cannot run them, and your keychain itself stays denied, to the helper and to `security` alike. Your `~/.npmrc` stays readable: the sandbox's hosts come from the registries it names, and npm needs its token to install from them.
56
56
  - **Writes**: the checkout and its git folder, but never where git takes settings or programs from (its config, hooks, `info`, a `commondir`, the submodules' and the other worktrees' git folders, a `.git` below the checkout's top; on Linux a `commondir` and a nested `.git` are removed at the end of the run instead, as the last paragraph of this section says); the run's scratch folder, where npm's cache and Corepack's package managers go for the run; and the package managers' own caches and stores (pnpm's store, Yarn's and Bun's caches, Prisma's engines), but never `~/.npm`, which your own npm and `npx` read from, nor pnpm's metadata cache, whose `dlx` folder your next `pnpm dlx` runs from (pnpm reads it and goes on without writing it), and of pnpm's home folder only its store, never the global commands it keeps on your PATH. Those stores and caches a sandboxed install could not make itself (pnpm's store on a machine where pnpm never ran, all of them on Linux) are made before the run starts, readable by you alone, and in pnpm's home nothing else is. A sandboxed pnpm is told to use that store, yours, so it never makes one of its own in the checkout, and to copy what it installs from it rather than hard-link it, so a write to an installed file changes only that copy. An install skips the browsers Cypress, Puppeteer and Playwright download, and husky's git hooks.
57
57
  - **Network**: only the hosts this setup needs: `--host`; GitHub's hosts an install fetches from (`github.com`, `api.github.com`, `codeload.github.com`, `*.githubusercontent.com`); the public registries your manifests and lockfiles install from (npm's, Yarn's, PyPI); the registries your own `~/.npmrc` names, never one only the repository names; Google's fonts for a Next.js app and Prisma's engines, when the app uses them; and Vercel's or Netlify's API once you said yes to setting that host up; never GitHub over SSH (port 22), which is refused with the reason, since what pushes runs outside the sandbox, as you. The debug log names each host it refused. The list limits where a command's traffic goes, not what it carries (a script can still send what it reads to GitHub or npm over HTTPS, which the sandbox's proxy passes through without reading), so what keeps your sign-ins yours is what the sandbox does not let it read.
58
58
 
@@ -62,7 +62,7 @@ The command stops when the sandbox does not start: on macOS inside another tool'
62
62
 
63
63
  The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it, whether their names say so or their values are shaped like a secret (a GitHub token, a private key, a service account's key, a password in a URL), and what runs as you (`git commit` and `push`, `gh`, the host CLIs) gets it without `GH_REPO` and `GH_HOST`, so `gh` works on this checkout's repository on github.com, whatever repository or host they name. The agent's own file tools (read, write, edit, grep, find and ls) open files in the command's process, as you, so for them the fence judges the path pi opens, not the string the agent typed: spelled the way pi spells it (a curly apostrophe for a straight one, among others) and with its symlinks followed, and a path that starts with `@` or `~` or a URL scheme, which pi would open elsewhere, is refused outright; read, write, edit, grep and ls judge each file once more as they open it (ls each entry too, so a symlink out of the checkout is not listed), find judges the folder it searches before fd walks it and every path fd reports after, and a symlinked folder out of the checkout is neither followed nor listed; the names of `.env` files are listed, as `ls -a` shows them, and only a read of one is refused; a grep glob that names `.env` files is refused, and no line of a `.env` file is in what grep reports, whatever glob it was given. It also reads what goes past it: text shaped like a secret (a cloud, GitHub, Google or SendGrid key, a private key, a token, a Slack webhook's address, a password in an address or on a curl command line) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, read as written and as the screen would show it once an escape sequence is taken out, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match. A commit message, a template and a pull request body come inline (`-m`, `--body`), never from a file: the file the fence read is not surely the file git or gh, outside the sandbox, read later, since a command still running in the sandbox can swap it in between. For the same reason the messages of the commits a push carries are read again as the push runs, all of them (past what the remote has: its branch of that name, the default branch, and one the pushed branch is stacked on, such as an earlier run's setup branch under a new test drive), and so is every line they add to a `.env` file's example, which a command may have written, with the same check as a file the agent writes; a push whose commits cannot be read whole is refused, as is one whose remote cannot be reached, since what it carries cannot be checked then (the push can be tried again once the network is back; the remote is asked with no prompt for a password or a passphrase, which would hold it), and one in the same command as the `git commit` or `git checkout -b` that makes what it carries, which is not there yet when it is read: the push runs as a command of its own, after the commit. A commit carries only files the agent wrote, or named in a `git add`: a file a script staged on its own is refused by name, and `git restore --staged` takes it out; a push whose new commits carry such a file (a script can commit on its own, past the index) is refused the same way. Once `impeccable_sync_workflow` wrote the preview workflow, or removed the retired one, a commit carries that file only as the tool left it: the setup state's bytes, or no file. A status line the agent starts a message with shows in the terminal and on the web page only when its progress tool would take it: in the product voice, short, and holding nothing shaped like a secret.
64
64
 
65
- Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
65
+ Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
66
66
 
67
67
  `vercel whoami` and `vercel --version`. Once you said yes to the Vercel setup: `vercel link --yes` in the checkout's root alone (never in a folder of it, whose name would pick one of your projects), `vercel project add` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, never `vercel project ls`, which names your projects, and a global install of the Vercel CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g vercel@<version>`; another package manager, version or tag is refused). The sign-in (`vercel login`) and the repository's connection (`vercel git connect`, for a project the run created or one connected to the repository already) are the run's own tools, never the agent's commands. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
68
68
 
package/dist/bin.js CHANGED
File without changes
package/dist/fence.js CHANGED
@@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
6
6
  import { createFindToolDefinition, createGrepToolDefinition, detectSupportedImageMimeTypeFromFile } from '@earendil-works/pi-coding-agent';
7
7
  import { linkedNetlifySite } from './netlify.js';
8
8
  import { resolveReadPath, resolveToCwd } from './pi-paths.js';
9
- import { gitEnvironment } from './sandbox.js';
9
+ import { gitEnvironment, NO_PROMPT } from './sandbox.js';
10
10
  import { DATA_NOTE, scan, scanRecorder } from './scan.js';
11
11
  import { plainText } from './ui/sensitive.js';
12
12
  const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
@@ -336,6 +336,35 @@ function printsPatch(sub, args) {
336
336
  const revisions = options.filter((a) => !a.startsWith('-'));
337
337
  return !(sub === 'show' && revisions.length && revisions.every((a) => a.includes(':')));
338
338
  }
339
+ /** git branch's listing flags, alone or in a cluster (-avv): -a, -r, -l, -v and -i. */
340
+ const BRANCH_FLAGS = /^-[arlvi]+$/;
341
+ const BRANCH_LONG_FLAGS = new Set(['--all', '--remotes', '--list', '--verbose', '--show-current', '--ignore-case', '--no-color', '--column', '--no-column', '--no-abbrev', '--omit-empty', '--contains', '--no-contains', '--merged', '--no-merged']);
342
+ /** Its options that take a value, with it after `=`: a bare `--contains HEAD` reads HEAD as its value, but a bare `--format %(refname)` would make a branch named %(refname). */
343
+ const BRANCH_VALUES = /^--(?:contains|no-contains|merged|no-merged|points-at|sort|format|color|abbrev)=/;
344
+ /** Whether `git branch` with these words only lists branches: a word that is no option names a pattern, which only --list takes (without it git makes a branch of that name); -d, -m, -c, -u, --set-upstream-to and --edit-description, among others, are not listing. */
345
+ function listsBranches(args) {
346
+ const options = args.filter((a) => a.startsWith('-'));
347
+ const patterns = args.length - options.length;
348
+ if (!options.every((a) => BRANCH_FLAGS.test(a) || BRANCH_LONG_FLAGS.has(a) || BRANCH_VALUES.test(a)))
349
+ return false;
350
+ return patterns === 0 || options.some((a) => a === '--list' || (BRANCH_FLAGS.test(a) && a.includes('l')));
351
+ }
352
+ /** Whether `git remote` with these words only lists: no words, -v, or get-url (with --push or --all) of one remote by name. Every other verb writes the config or fetches, and show without -n asks the remote. */
353
+ function listsRemotes(args) {
354
+ if (!args.length || (args.length === 1 && (args[0] === '-v' || args[0] === '--verbose')))
355
+ return true;
356
+ const [verb, ...rest] = args;
357
+ return verb === 'get-url' && rest.filter((a) => a.startsWith('-')).every((a) => a === '--push' || a === '--all') && rest.filter((a) => !a.startsWith('-')).length === 1;
358
+ }
359
+ /** git ls-remote's listing options, alone or in a cluster (-ht). */
360
+ const LS_REMOTE_FLAGS = /^-[hbtq]+$/;
361
+ const LS_REMOTE_LONG = new Set(['--heads', '--branches', '--tags', '--refs', '--symref', '--quiet', '--exit-code', '--get-url']);
362
+ /** Whether `git ls-remote` with these words asks origin, named, and only lists: the first word that is no option is the repository, so a pattern comes after `origin`, and with none git asks the current branch's own remote, whichever that is; a URL, another remote, --upload-pack (a program) and -o (sent to the server) are not listing. */
363
+ function listsOrigin(args) {
364
+ const options = args.filter((a) => a.startsWith('-'));
365
+ const words = args.filter((a) => !a.startsWith('-'));
366
+ return options.every((a) => LS_REMOTE_FLAGS.test(a) || LS_REMOTE_LONG.has(a) || a.startsWith('--sort=')) && words[0] === 'origin';
367
+ }
339
368
  /** The branch a push's refspec updates on the remote: its destination, else its source, without `+` and `refs/heads/` (or `heads/`, which git reads the same). */
340
369
  const pushedBranch = (refspec) => refspec.replace(/^\+/, '').split(':').pop().replace(/^(?:refs\/)?heads\//, '');
341
370
  const isFile = (path) => {
@@ -440,6 +469,12 @@ function git(args, where, protectedBranches, held = false) {
440
469
  }
441
470
  if (sub === 'checkout' && rest[0] === '-b' && rest.length >= 2 && rest.length <= 3)
442
471
  return allow();
472
+ if (sub === 'branch')
473
+ return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b makes a branch');
474
+ if (sub === 'remote')
475
+ return listsRemotes(rest) ? allow() : deny('git remote lists only: on its own, -v, or get-url <name>; nothing that adds, removes, renames, points or prunes a remote, and no show, which asks the remote');
476
+ if (sub === 'ls-remote')
477
+ return listsOrigin(rest) ? allow() : deny('git ls-remote asks origin only, named: git ls-remote --heads origin, with --tags, --refs, --symref, --get-url and patterns after origin; no other remote, no URL, no --upload-pack, and not without origin, which asks the current branch\'s own remote');
443
478
  if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
444
479
  const branch = rest[2];
445
480
  if (branch.includes(':') || ['HEAD', '@'].includes(pushedBranch(branch)) || protectedBranches.includes(pushedBranch(branch)))
@@ -909,7 +944,7 @@ function newMessages(head, bases, cwd) {
909
944
  */
910
945
  function unprompted(cwd) {
911
946
  const ownSsh = process.env.GIT_SSH_COMMAND || process.env.GIT_SSH || gitRead(['config', '--get', 'core.sshCommand'], cwd);
912
- return { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', ...(ownSsh ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }) };
947
+ return { ...NO_PROMPT, ...(ownSsh ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }) };
913
948
  }
914
949
  /**
915
950
  * What a `push -u origin <branch>` adds to, asked of the remote itself: the commits it has on that branch and on the default
@@ -138,7 +138,7 @@ export const HOST_SCREENS = {
138
138
  id: 'vercel-bypass',
139
139
  kind: 'confirm',
140
140
  title: 'Your Vercel previews are protected',
141
- hint: 'Impeccable reviews each pull request on its preview, and Vercel keeps yours behind a login. Vercel\'s way in for tools is a bypass secret made for one tool: the run creates one in your project, named "Impeccable reviews". It is stored encrypted, used only to open previews, and you can revoke it any time in Settings → Deployment Protection.',
141
+ hint: 'Impeccable reviews each pull request on its preview,\nand Vercel keeps yours behind a login.\n\nVercel\'s way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time.',
142
142
  prompt: 'Create the secret for Impeccable?',
143
143
  confirmLabel: 'Create',
144
144
  skipLabel: 'Skip',
@@ -156,7 +156,7 @@ export const HOST_SCREENS = {
156
156
  id: 'netlify-password',
157
157
  kind: 'confirm',
158
158
  title: 'Your Netlify previews are protected',
159
- hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password. The run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
159
+ hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password.\n\nThe run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
160
160
  prompt: 'Store the site password for Impeccable?',
161
161
  confirmLabel: 'Store',
162
162
  skipLabel: 'Skip',
@@ -193,7 +193,7 @@ export const HOST_SCREENS = {
193
193
  id: 'cloudflare-access',
194
194
  kind: 'confirm',
195
195
  title: 'Your Cloudflare previews are protected',
196
- 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.',
196
+ hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login.\n\nCloudflare\'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.',
197
197
  prompt: 'Create the token for Impeccable?',
198
198
  confirmLabel: 'Create',
199
199
  skipLabel: 'Skip',
@@ -216,13 +216,13 @@ export const HOST_SCREENS = {
216
216
  },
217
217
  };
218
218
  /**
219
- * A key's screen naming the one address the key goes to: the consent to a bypass secret, a site password or a service
220
- * token is to that address, which the key opens before anything saves it. A confirm says so in its supporting text; a
221
- * text or account screen, under its prompt.
219
+ * A key's paste screen naming the one address the key goes to, which the key opens before anything saves it: a text
220
+ * or account screen says so under its prompt. A confirm screen's consent to a bypass secret, a site password or a
221
+ * service token no longer names the address, so it comes back as it is.
222
222
  */
223
223
  export function sentTo(screen, url) {
224
224
  const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
- return screen.kind === 'confirm' ? { ...screen, hint: `${screen.hint} ${line}` } : { ...screen, prompt: `${screen.prompt} ${line}` };
225
+ return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
226
226
  }
227
227
  /**
228
228
  * The paste screen for Vercel's bypass secret, saying what actually stopped the run's own way (the CLI's read, Vercel's
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const LIMIT = 20;
5
+ const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
6
+ const FAILURE = 'GitHub preview evidence could not be read completely.';
7
+ const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
8
+ function record(value) {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : undefined;
12
+ }
13
+ function positiveInteger(value) {
14
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
15
+ }
16
+ function sha(value) {
17
+ return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
18
+ }
19
+ function previewUrl(value) {
20
+ if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
21
+ return null;
22
+ try {
23
+ const url = new URL(value);
24
+ if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
25
+ return null;
26
+ return url.href;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
33
+ export async function readHostedPreviews(owner, repo, dependencies = {}) {
34
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
35
+ || !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
36
+ return { coverage: COVERAGE, deployments: [], error: FAILURE };
37
+ }
38
+ const runGh = dependencies.runGh ?? (async (args) => {
39
+ const { stdout } = await execFileAsync('gh', args, {
40
+ encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
41
+ });
42
+ return stdout;
43
+ });
44
+ const base = `/repos/${owner}/${repo}`;
45
+ async function get(route) {
46
+ if (dependencies.signal?.aborted)
47
+ throw new Error(FAILURE);
48
+ const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
49
+ if (dependencies.signal?.aborted)
50
+ throw new Error(FAILURE);
51
+ const data = JSON.parse(stdout);
52
+ if (!Array.isArray(data))
53
+ throw new Error(FAILURE);
54
+ return data.slice(0, LIMIT);
55
+ }
56
+ try {
57
+ const [deployments, pulls] = await Promise.all([
58
+ get(`${base}/deployments?per_page=${LIMIT}`),
59
+ get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
60
+ ]);
61
+ let incomplete = false;
62
+ const evidence = [];
63
+ for (const entry of deployments) {
64
+ if (dependencies.signal?.aborted) {
65
+ incomplete = true;
66
+ break;
67
+ }
68
+ const deployment = record(entry);
69
+ if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
70
+ continue;
71
+ let latest;
72
+ try {
73
+ latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
74
+ }
75
+ catch {
76
+ incomplete = true;
77
+ if (dependencies.signal?.aborted)
78
+ break;
79
+ }
80
+ const status = STATES.find((state) => state === latest?.state) ?? null;
81
+ const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
82
+ && !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
83
+ const pullRequests = [];
84
+ for (const item of pulls) {
85
+ const pull = record(item);
86
+ if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
87
+ && record(pull.head)?.sha === deployment.sha) {
88
+ pullRequests.push({ number: pull.number, state: pull.state });
89
+ }
90
+ }
91
+ evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
92
+ previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
93
+ }
94
+ incomplete ||= dependencies.signal?.aborted ?? false;
95
+ return { coverage: COVERAGE, deployments: evidence,
96
+ ...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
97
+ }
98
+ catch {
99
+ return { coverage: COVERAGE, deployments: [], error: FAILURE };
100
+ }
101
+ }
@@ -0,0 +1,24 @@
1
+ /** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
2
+ export function netlifyPatternError(body, observed) {
3
+ const pattern = body.previewUrlPattern;
4
+ if (typeof pattern !== 'string')
5
+ return null;
6
+ let netlify = /netlify\.app|<site>/i.test(pattern);
7
+ try {
8
+ const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
9
+ netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
10
+ }
11
+ catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
12
+ if (!netlify)
13
+ return null;
14
+ const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
15
+ if (!match || match[1].length > 63)
16
+ return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
17
+ if (Object.hasOwn(body, 'appKey'))
18
+ return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
19
+ if (body.intendedMethod !== 'preview-deploy')
20
+ return 'Save a Netlify pattern with intendedMethod preview-deploy.';
21
+ if (!observed.has(pattern))
22
+ return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
23
+ return null;
24
+ }
@@ -0,0 +1,144 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const FAILURE = 'Netlify preview evidence could not be read completely.';
5
+ const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
6
+ const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
7
+ const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
8
+ const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
9
+ const STATES = ['success', 'pending', 'failure', 'error'];
10
+ function record(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ ? value : undefined;
13
+ }
14
+ function cleanUrl(value) {
15
+ if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
16
+ return;
17
+ try {
18
+ const url = new URL(value);
19
+ if (!url.username && !url.password && !url.port && !url.search && !url.hash)
20
+ return url;
21
+ }
22
+ catch { /* Discard unsafe evidence without returning it. */ }
23
+ }
24
+ function urls(pull, site) {
25
+ if (`deploy-preview-${pull.number}--${site}`.length > 63)
26
+ return;
27
+ return {
28
+ previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
29
+ previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
30
+ };
31
+ }
32
+ function validTarget(value, pull, site) {
33
+ if (value === null || value === undefined || value === '')
34
+ return true;
35
+ const target = cleanUrl(value);
36
+ if (!target)
37
+ return false;
38
+ if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
39
+ return true;
40
+ return target.hostname === 'app.netlify.com'
41
+ && new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
42
+ }
43
+ /** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
44
+ export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
45
+ const previews = [];
46
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
47
+ || !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
48
+ return { coverage: COVERAGE, previews, error: FAILURE };
49
+ }
50
+ const runGh = dependencies.runGh ?? (async (args) => {
51
+ const { stdout } = await execFileAsync('gh', args, {
52
+ encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
53
+ });
54
+ return stdout;
55
+ });
56
+ const base = `/repos/${owner}/${repo}`;
57
+ async function get(route, limit) {
58
+ if (dependencies.signal?.aborted)
59
+ throw new Error();
60
+ const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
61
+ if (dependencies.signal?.aborted)
62
+ throw new Error();
63
+ const data = JSON.parse(raw);
64
+ if (!Array.isArray(data))
65
+ throw new Error();
66
+ return data.slice(0, limit);
67
+ }
68
+ const [pullRead, commentRead] = await Promise.allSettled([
69
+ get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
70
+ get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
71
+ ]);
72
+ if (pullRead.status === 'rejected')
73
+ return { coverage: COVERAGE, previews, error: FAILURE };
74
+ let incomplete = false;
75
+ const warnings = commentRead.status === 'rejected'
76
+ ? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
77
+ const pulls = new Map();
78
+ for (const item of pullRead.value) {
79
+ const pull = record(item);
80
+ const headSha = record(pull?.head)?.sha;
81
+ if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
82
+ || (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
83
+ continue;
84
+ const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
85
+ if (!pulls.has(issueUrl))
86
+ pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
87
+ }
88
+ const seen = new Set();
89
+ const statusesBySha = new Map();
90
+ for (const pull of pulls.values()) {
91
+ if (dependencies.signal?.aborted) {
92
+ incomplete = true;
93
+ break;
94
+ }
95
+ try {
96
+ if (!statusesBySha.has(pull.headSha)) {
97
+ statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
98
+ }
99
+ for (const item of await statusesBySha.get(pull.headSha)) {
100
+ const status = record(item);
101
+ const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
102
+ if (!site || seen.has(`${pull.number}:${site}`))
103
+ continue;
104
+ // GitHub returns newest first: even an invalid latest entry must hide older success.
105
+ seen.add(`${pull.number}:${site}`);
106
+ const state = STATES.find((state) => state === status?.state);
107
+ const canonical = urls(pull, site);
108
+ if (!state || !canonical || !validTarget(status?.target_url, pull, site))
109
+ continue;
110
+ previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
111
+ status: state, ...canonical, source: 'commit-status', matchesHead: true });
112
+ }
113
+ }
114
+ catch {
115
+ incomplete = true;
116
+ }
117
+ }
118
+ if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
119
+ for (const item of commentRead.value) {
120
+ const comment = record(item);
121
+ const author = record(comment?.user);
122
+ const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
123
+ if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
124
+ || typeof comment?.body !== 'string' || comment.body.length > 65_536)
125
+ continue;
126
+ for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
127
+ const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
128
+ const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
129
+ if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
130
+ continue;
131
+ const site = parts[2];
132
+ const canonical = urls(pull, site);
133
+ if (!canonical || seen.has(`${pull.number}:${site}`))
134
+ continue;
135
+ seen.add(`${pull.number}:${site}`);
136
+ previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
137
+ status: null, ...canonical, source: 'bot-comment', matchesHead: false });
138
+ }
139
+ }
140
+ }
141
+ if (dependencies.signal?.aborted)
142
+ incomplete = true;
143
+ return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
144
+ }
@@ -0,0 +1,67 @@
1
+ function redirectGate(location, source) {
2
+ if (!location)
3
+ return 'redirected';
4
+ try {
5
+ const target = new URL(location, source);
6
+ if (target.protocol !== 'https:')
7
+ return 'redirected';
8
+ if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
9
+ && target.searchParams.has('url'))
10
+ return 'vercel-protection';
11
+ if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
12
+ && /^\/login(?:\/|$)/.test(target.pathname))
13
+ return 'vercel-protection';
14
+ if (target.hostname.endsWith('.cloudflareaccess.com'))
15
+ return 'cloudflare-access';
16
+ if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
17
+ return 'app-sign-in';
18
+ }
19
+ }
20
+ catch {
21
+ // A malformed redirect reveals nothing about preview protection.
22
+ }
23
+ return 'redirected';
24
+ }
25
+ /** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
26
+ export async function probePreview(url, dependencies = {}) {
27
+ let target;
28
+ try {
29
+ target = new URL(url);
30
+ if (!isPlatformPreview(target)
31
+ || /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
32
+ throw new Error();
33
+ }
34
+ catch {
35
+ return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
36
+ }
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), 15_000);
39
+ const signal = dependencies.signal
40
+ ? AbortSignal.any([controller.signal, dependencies.signal])
41
+ : controller.signal;
42
+ try {
43
+ signal.throwIfAborted();
44
+ const response = dependencies.fetch ? await dependencies.fetch(target, {
45
+ method: 'GET',
46
+ headers: { accept: 'text/html' },
47
+ credentials: 'omit',
48
+ redirect: 'manual',
49
+ signal,
50
+ }) : await publicPreviewRequest(target, signal);
51
+ const status = response.status;
52
+ const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
53
+ ? 'vercel-protection' : status >= 300 && status < 400
54
+ ? redirectGate(response.headers.get('location'), target)
55
+ : status >= 200 && status < 300 ? 'reachable' : 'unavailable';
56
+ // Bodies and redirect details may contain credentials; never read or return them.
57
+ await response.body?.cancel().catch(() => undefined);
58
+ return { status, gate };
59
+ }
60
+ catch {
61
+ return { status: null, gate: 'unavailable', error: 'request-failed' };
62
+ }
63
+ finally {
64
+ clearTimeout(timeout);
65
+ }
66
+ }
67
+ import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
@@ -0,0 +1,15 @@
1
+ /** Register credential-shaped write fields before a message can be recorded.
2
+ * Do not trust the requested path: even a refused or malformed destination must
3
+ * keep its supplied credentials private. Values remain unchanged in memory.
4
+ */
5
+ export function protectionWriteSecrets(input) {
6
+ if (!input || typeof input !== 'object' || Array.isArray(input))
7
+ return [];
8
+ const body = input.body;
9
+ if (!body || typeof body !== 'object' || Array.isArray(body))
10
+ return [];
11
+ return ['value', 'clientId', 'clientSecret']
12
+ .map((key) => body[key])
13
+ .filter((value) => typeof value === 'string' && value.length > 0)
14
+ .sort((a, b) => b.length - a.length);
15
+ }
@@ -0,0 +1,96 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { request } from 'node:https';
3
+ import { BlockList, isIP } from 'node:net';
4
+ const blockedV4 = new BlockList();
5
+ for (const [address, prefix] of [
6
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
7
+ ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
8
+ ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
9
+ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
10
+ ])
11
+ blockedV4.addSubnet(address, prefix, 'ipv4');
12
+ const publicV6 = new BlockList();
13
+ publicV6.addSubnet('2000::', 3, 'ipv6');
14
+ const blockedV6 = new BlockList();
15
+ // Conservatively exclude special-purpose, transition and documentation prefixes.
16
+ for (const [address, prefix] of [
17
+ ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
18
+ ])
19
+ blockedV6.addSubnet(address, prefix, 'ipv6');
20
+ export function isPlatformPreview(target) {
21
+ return target.protocol === 'https:' && !target.username && !target.password && !target.port
22
+ && !/[?#]/.test(target.href)
23
+ && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
24
+ }
25
+ function isPublicAddress(value) {
26
+ if (!value || typeof value.address !== 'string' || value.address.includes('%'))
27
+ return false;
28
+ const family = isIP(value.address);
29
+ if (family !== value.family)
30
+ return false;
31
+ if (family === 4)
32
+ return !blockedV4.check(value.address, 'ipv4');
33
+ return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
34
+ }
35
+ /** Resolve once, validate every answer, then connect only to the pinned public addresses. */
36
+ export async function publicPreviewRequest(target, signal, dependencies = {}) {
37
+ try {
38
+ if (!isPlatformPreview(target))
39
+ throw new Error();
40
+ signal.throwIfAborted();
41
+ let abortDns = () => { };
42
+ const aborted = new Promise((_resolve, reject) => {
43
+ abortDns = () => reject(new Error());
44
+ signal.addEventListener('abort', abortDns, { once: true });
45
+ });
46
+ const addresses = await Promise.race([
47
+ (dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
48
+ ]).finally(() => signal.removeEventListener('abort', abortDns));
49
+ signal.throwIfAborted();
50
+ if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
51
+ throw new Error();
52
+ const pinned = addresses.map(({ address, family }) => ({ address, family }));
53
+ return await new Promise((resolve, reject) => {
54
+ const options = {
55
+ method: 'GET', headers: { accept: 'text/html' }, signal,
56
+ agent: false, autoSelectFamily: true, rejectUnauthorized: true,
57
+ lookup(hostname, options, callback) {
58
+ if (hostname !== target.hostname)
59
+ return callback(new Error('Preview request failed'), '');
60
+ if (options.all)
61
+ callback(null, pinned.map((address) => ({ ...address })));
62
+ else
63
+ callback(null, pinned[0].address, pinned[0].family);
64
+ },
65
+ };
66
+ const req = (dependencies.request ?? request)(target, options, (response) => {
67
+ response.once('error', () => { });
68
+ try {
69
+ const status = response.statusCode;
70
+ const headers = new Headers();
71
+ for (const name of ['location', 'x-vercel-error']) {
72
+ const value = response.headers[name];
73
+ if (typeof value === 'string')
74
+ headers.set(name, value);
75
+ }
76
+ if (!status)
77
+ reject(new Error());
78
+ else
79
+ resolve({ status, headers, body: null });
80
+ }
81
+ catch {
82
+ reject(new Error());
83
+ }
84
+ finally {
85
+ response.destroy();
86
+ }
87
+ });
88
+ req.once('error', () => reject(new Error()));
89
+ req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
90
+ req.end();
91
+ });
92
+ }
93
+ catch {
94
+ throw new Error('Preview request failed');
95
+ }
96
+ }
@@ -0,0 +1,46 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ const INTERVAL_MS = 30_000;
3
+ const DEADLINE_MS = 8 * 60_000;
4
+ /** One publication deadline per setup PR, including time spent checking the preview. */
5
+ export function createPublicationWait(dependencies = {}) {
6
+ const now = dependencies.now ?? (() => performance.now());
7
+ const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
8
+ const starts = new Map();
9
+ return {
10
+ begin(pr) {
11
+ if (!starts.has(pr))
12
+ starts.set(pr, now());
13
+ },
14
+ remainingMs(pr) {
15
+ const start = starts.get(pr);
16
+ return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
17
+ },
18
+ async wait(pr, signal) {
19
+ const start = starts.get(pr);
20
+ if (start === undefined)
21
+ return null;
22
+ const result = (status) => {
23
+ const elapsed = Math.max(0, now() - start);
24
+ return {
25
+ status,
26
+ waited_seconds: Math.floor(elapsed / 1000),
27
+ remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
28
+ };
29
+ };
30
+ if (signal?.aborted)
31
+ return result('cancelled');
32
+ const remaining = DEADLINE_MS - (now() - start);
33
+ if (remaining <= 0)
34
+ return result('timed-out');
35
+ try {
36
+ await pause(Math.min(INTERVAL_MS, remaining), signal);
37
+ }
38
+ catch {
39
+ return result('cancelled');
40
+ }
41
+ if (signal?.aborted)
42
+ return result('cancelled');
43
+ return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,121 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, realpath } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ const METADATA_LIMIT = 16 * 1024;
6
+ const OUTPUT_LIMIT = 2 * 1024 * 1024;
7
+ function record(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
9
+ ? value : undefined;
10
+ }
11
+ /** Only the root's small project-link metadata is read; CLI authentication stays with the CLI. */
12
+ async function linkedProject(cwd) {
13
+ try {
14
+ const root = await realpath(cwd);
15
+ const directory = join(root, '.vercel');
16
+ const directoryStat = await lstat(directory);
17
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
18
+ return;
19
+ const path = join(directory, 'project.json');
20
+ if (!(await lstat(path)).isFile())
21
+ return;
22
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
23
+ try {
24
+ const stat = await file.stat();
25
+ const currentDirectory = await lstat(directory);
26
+ const currentFile = await lstat(path);
27
+ if (!stat.isFile() || stat.size > METADATA_LIMIT
28
+ || currentDirectory.dev !== directoryStat.dev || currentDirectory.ino !== directoryStat.ino
29
+ || currentFile.isSymbolicLink() || currentFile.dev !== stat.dev || currentFile.ino !== stat.ino
30
+ || await realpath(path) !== path)
31
+ return;
32
+ const buffer = Buffer.alloc(METADATA_LIMIT + 1);
33
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
34
+ if (bytesRead > METADATA_LIMIT)
35
+ return;
36
+ const metadata = record(JSON.parse(buffer.toString('utf8', 0, bytesRead)));
37
+ if (typeof metadata?.projectId !== 'string' || !/^prj_[A-Za-z0-9]{1,128}$/.test(metadata.projectId)
38
+ || typeof metadata.orgId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(metadata.orgId))
39
+ return;
40
+ return { root, projectId: metadata.projectId, orgId: metadata.orgId };
41
+ }
42
+ finally {
43
+ await file.close();
44
+ }
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ }
50
+ /** Reuse one existing default automation bypass in memory, without exposing project responses or keys. */
51
+ export async function useExistingVercelBypass(input, dependencies) {
52
+ const { signal } = dependencies;
53
+ if (signal?.aborted)
54
+ return { status: 'cancelled' };
55
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input.owner)
56
+ || !/^[a-zA-Z0-9_.-]{1,100}$/.test(input.repo) || input.repo === '.' || input.repo === '..') {
57
+ return { status: 'project-mismatch' };
58
+ }
59
+ const linked = await linkedProject(input.cwd);
60
+ if (signal?.aborted)
61
+ return { status: 'cancelled' };
62
+ if (!linked)
63
+ return { status: 'not-linked' };
64
+ const runVercel = dependencies.runVercel ?? ((args) => new Promise((resolve, reject) => {
65
+ const child = execFile('vercel', args, {
66
+ cwd: linked.root, encoding: 'utf8', timeout: 30_000, maxBuffer: OUTPUT_LIMIT, signal,
67
+ }, (error, stdout) => error ? reject(new Error('Vercel project lookup failed.')) : resolve(stdout));
68
+ child.stdin?.end();
69
+ }));
70
+ // teamId selects the linked team. Legacy personal projects omit it and still require accountId to match.
71
+ const route = `/v9/projects/${linked.projectId}${linked.orgId.startsWith('team_') ? `?teamId=${linked.orgId}` : ''}`;
72
+ let selected;
73
+ try {
74
+ const stdout = await runVercel(['api', route, '--method', 'GET', '--raw', '--non-interactive'], signal);
75
+ if (signal?.aborted)
76
+ return { status: 'cancelled' };
77
+ if (Buffer.byteLength(stdout, 'utf8') > OUTPUT_LIMIT)
78
+ return { status: 'unavailable' };
79
+ const project = record(JSON.parse(stdout));
80
+ const link = record(project?.link);
81
+ if (project?.id !== linked.projectId || project.accountId !== linked.orgId
82
+ || !link || !['github', 'github-limited'].includes(String(link.type))
83
+ || typeof link.org !== 'string' || link.org.toLowerCase() !== input.owner.toLowerCase()
84
+ || typeof link.repo !== 'string' || link.repo.toLowerCase() !== input.repo.toLowerCase()) {
85
+ return { status: 'project-mismatch' };
86
+ }
87
+ const bypasses = record(project.protectionBypass);
88
+ if (!bypasses || Object.keys(bypasses).length > 128)
89
+ return { status: 'unavailable' };
90
+ const candidates = [];
91
+ for (const [value, metadata] of Object.entries(bypasses)) {
92
+ const bypass = record(metadata);
93
+ if (bypass?.scope !== 'automation-bypass')
94
+ continue;
95
+ if (!/^[A-Za-z0-9]{32}$/.test(value)
96
+ || (bypass.isEnvVar !== undefined && typeof bypass.isEnvVar !== 'boolean'))
97
+ return { status: 'unavailable' };
98
+ // An omitted isEnvVar is the legacy default. Explicit false keys must never be chosen implicitly.
99
+ if (bypass.isEnvVar !== false)
100
+ candidates.push(value);
101
+ }
102
+ if (candidates.length > 1)
103
+ return { status: 'ambiguous' };
104
+ if (candidates.length === 0)
105
+ return { status: 'unavailable' };
106
+ selected = candidates[0];
107
+ dependencies.rememberSecret(selected);
108
+ }
109
+ catch {
110
+ return { status: signal?.aborted ? 'cancelled' : 'unavailable' };
111
+ }
112
+ if (signal?.aborted)
113
+ return { status: 'cancelled' };
114
+ try {
115
+ const status = await dependencies.save(selected);
116
+ return { status: ['saved', 'save-failed', 'partial-save', 'cancelled'].includes(status) ? status : 'save-failed' };
117
+ }
118
+ catch {
119
+ return { status: signal?.aborted ? 'cancelled' : 'save-failed' };
120
+ }
121
+ }
package/dist/sandbox.js CHANGED
@@ -122,7 +122,8 @@ function moved(env, gitConfigs, kept) {
122
122
  }
123
123
  /**
124
124
  * What a command in the sandbox reads and writes: it reads what the person can but the credential folders, where their
125
- * environment moved them, and the files git's global settings come from (`gitConfigs`), and writes
125
+ * environment moved them, and the files git's global settings come from and the credential helper programs its system
126
+ * settings name (`gitConfigs`), and writes
126
127
  * in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
127
128
  * never where git reads settings or programs from, nor a .git below the checkout's top. Bubblewrap takes no pattern in
128
129
  * a write rule, and puts a file in place of a denied path that is not there: on Linux the names git reads as nothing
@@ -145,7 +146,7 @@ export function sandboxPolicy(o) {
145
146
  };
146
147
  }
147
148
  /**
148
- * The global git settings a command in the sandbox keeps: the person's name and email, what shapes how git reads the
149
+ * The git settings, global and system, a command in the sandbox keeps: the person's name and email, what shapes how git reads the
149
150
  * checkout (line endings, case, excludes, attributes, and the content filters, which git add runs: of their commands only
150
151
  * LFS's, see LFS_COMMAND, and every filter's `required`) and safe.directory. Nothing that carries or names a sign-in (http's
151
152
  * headers, proxy, cookies and certificates, url rewrites, credential helpers, sendemail's and imap's passwords, includes),
@@ -158,27 +159,46 @@ const KEPT_SETTINGS = /^(?:user\.(?:name|email)|init\.defaultbranch|core\.(?:aut
158
159
  * filtered rather than stage it unfiltered.
159
160
  */
160
161
  const LFS_COMMAND = /^git(?:-| +)lfs +(?:clean|smudge|filter-process)(?: +--skip)?(?: +(?:-- +)?%f)?$/;
161
- /** git's `-z --show-origin` listing, setting by setting: the file it came from, its key, and its value when it has one. */
162
+ /** git's `-z --show-scope --show-origin` listing, setting by setting: its scope, the file it came from, its key, and its value when it has one. */
162
163
  function* settings(listing) {
163
164
  const parts = listing.split('\0');
164
- for (let i = 0; i + 1 < parts.length; i += 2) {
165
- const origin = parts[i], entry = parts[i + 1];
165
+ for (let i = 0; i + 2 < parts.length; i += 3) {
166
+ const scope = parts[i], origin = parts[i + 1], entry = parts[i + 2];
166
167
  const at = entry.indexOf('\n');
167
- yield { file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
168
+ yield { scope, file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
168
169
  }
169
170
  }
170
171
  /**
171
- * The person's global git settings scrubbed for the sandbox, from git's own `-z --show-origin` listing of them (`file:path`,
172
- * NUL, the key, a newline and the value, NUL, per setting; the includes that hold for this checkout already read in): the
173
- * text of a config file that holds the kept settings, and the files they came from, for the deny list.
172
+ * The scopes outside the checkout, whose settings the sandbox's copy takes: the system config and the global one. Apple's
173
+ * git reads the config bundled with it (under Xcode or the Command Line Tools, `usr/share/git-core/gitconfig`, which names
174
+ * the osxkeychain credential helper) under no scope git has a name for, so `--show-scope` says `unknown`; GIT_CONFIG_NOSYSTEM
175
+ * turns that file off with /etc/gitconfig. The checkout's own settings (local, worktree) git in the sandbox reads as they
176
+ * are, and the command's own (`-c`, GIT_CONFIG_PARAMETERS) it gets in its environment.
177
+ */
178
+ const OUTSIDE_SCOPES = new Set(['system', 'unknown', 'global']);
179
+ /**
180
+ * The person's git settings scrubbed for the sandbox, from git's own `-z --show-scope --show-origin` listing of every scope
181
+ * (the scope, NUL, `file:path`, NUL, the key, a newline and the value, NUL, per setting; the includes that hold for this
182
+ * checkout already read in): the text of a config file that holds the kept settings of the system and global scopes, in
183
+ * git's order, so a global setting still wins over the system one, and the global files they came from, for the deny list.
184
+ * The system files (Apple git's under Xcode or the Command Line Tools, /etc/gitconfig) hold no sign-in of the person's and
185
+ * GIT_CONFIG_NOSYSTEM keeps git in the sandbox from reading them, so they are not denied; the checkout's own settings git
186
+ * reads from the checkout, which the copy does not repeat.
174
187
  */
175
188
  export function scrubGitConfig(listing) {
176
189
  const quoted = (text) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`;
177
190
  const files = new Set();
191
+ const helpers = new Set();
178
192
  const sections = new Map();
179
- for (const { file, key, value } of settings(listing)) {
180
- if (file)
193
+ for (const { scope, file, key, value } of settings(listing)) {
194
+ if (!OUTSIDE_SCOPES.has(scope))
195
+ continue;
196
+ if (file && scope === 'global')
181
197
  files.add(file);
198
+ // A system config's helper by its bare name (`osxkeychain`, `libsecret`): the program git-credential-<name> on git's exec path, which no sandboxed command is to run.
199
+ const named = scope !== 'global' && /^credential(?:\..+)?\.helper$/i.test(key) ? /^([^\s!/]+)(?:\s|$)/.exec(value ?? '')?.[1] : undefined;
200
+ if (named)
201
+ helpers.add(named);
182
202
  if (!KEPT_SETTINGS.test(key) || (/^filter\..+\.(?:clean|smudge|process)$/i.test(key) && !LFS_COMMAND.test(value ?? '')))
183
203
  continue;
184
204
  const section = key.slice(0, key.indexOf('.')), name = key.slice(key.lastIndexOf('.') + 1);
@@ -188,19 +208,23 @@ export function scrubGitConfig(listing) {
188
208
  lines.push(value === undefined ? `\t${name}` : `\t${name} = ${quoted(value)}`);
189
209
  sections.set(header, lines);
190
210
  }
191
- return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files] };
211
+ return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files], helpers: [...helpers] };
192
212
  }
213
+ /** Where Apple's gits keep their programs, git-credential-osxkeychain among them: the one xcode-select names answers `git --exec-path`, the other stays, and a script could run either. */
214
+ const APPLE_GIT_CORE = ['/Applications/Xcode.app/Contents/Developer/usr/libexec/git-core', '/Library/Developer/CommandLineTools/usr/libexec/git-core'];
193
215
  /** A setting that names a file git includes: include.path, and includeIf.<condition>.path, whose condition may not hold for this checkout. */
194
216
  const INCLUDE = /^include(?:if\..*)?\.path$/i;
195
217
  /**
196
218
  * Every file the settings in a listing include, whether the include holds for this checkout or not, since a token sits as
197
219
  * readily behind an includeIf for another one: `~/` from the home folder, a relative path from the including file's folder,
198
- * an absolute path as it is, and never another user's `~user/`, which only git resolves.
220
+ * an absolute path as it is, and never another user's `~user/`, which only git resolves. Of one scope's settings only when
221
+ * `scope` is given: a listing of every scope carries the checkout's own includes too, which git in the sandbox reads as
222
+ * they are, so they are not for the deny list.
199
223
  */
200
- export function gitIncludes(listing, home) {
224
+ export function gitIncludes(listing, home, scope) {
201
225
  const named = new Set();
202
- for (const { file, key, value } of settings(listing)) {
203
- if (!INCLUDE.test(key) || value === undefined)
226
+ for (const { scope: own, file, key, value } of settings(listing)) {
227
+ if ((scope !== undefined && own !== scope) || !INCLUDE.test(key) || value === undefined)
204
228
  continue;
205
229
  if (value.startsWith('~/'))
206
230
  named.add(join(home, value.slice(2)));
@@ -212,46 +236,55 @@ export function gitIncludes(listing, home) {
212
236
  return [...named];
213
237
  }
214
238
  /**
215
- * The scrubbed copy of the person's global git settings, read as the person from the checkout (an includeIf for it holds)
216
- * and written where no sandboxed command writes, and the files the settings came from or include, held or not, each read
217
- * for what it includes in turn. Without a global config git says so and exits 128, and the copy is empty: git in the
218
- * sandbox then reads the checkout's own settings only. A global config or an include that holds for this checkout that git
219
- * cannot read otherwise (a bad line, a file it may not open) stops the run, with git's words: what it includes in turn
220
- * would stay readable, unknown to the deny list. One held for another checkout only is named in the debug log and stays
221
- * denied, and the run goes on, though what it includes in turn stays unknown.
239
+ * The scrubbed copy of the person's git settings, system and global, read as the person from the checkout (an includeIf
240
+ * for it holds): the system scope from a listing of every scope with the global config turned off, since git lists the
241
+ * system config Apple's git reads under no scope's name only without a scope flag, and `--system` names /etc/gitconfig
242
+ * alone; the global scope from its own listing, which dies on a global config git may not open where the listing of every
243
+ * scope would skip it. Written where no sandboxed command writes; and the global files the settings came from or include,
244
+ * held or not, each read for what it includes in turn. Without a global config git says so and exits 128, and the copy
245
+ * holds the system settings alone, if any: git in the sandbox then reads the checkout's own settings besides. A config or
246
+ * an include that holds for this checkout that git cannot read otherwise (a bad line, a file it may not open) stops the
247
+ * run, with git's words: what it includes in turn would stay readable, unknown to the deny list. One held for another
248
+ * checkout only is named in the debug log and stays denied, and the run goes on, though what it includes in turn stays
249
+ * unknown. Also the credential helper programs the system config names, where they are (git's exec path, Apple's two), for
250
+ * the deny list: GIT_CONFIG_NOSYSTEM keeps git in the sandbox from naming them, and a script that unsets it, or runs one by
251
+ * its path, finds the program unreadable, so no keychain is asked (the denied keychain folder would answer it nothing anyway).
222
252
  */
223
253
  function scrubbedGitConfig(root, home, log) {
224
254
  const env = gitEnvironment({ ...process.env, HOME: home });
225
- const list = (args, unread) => {
255
+ const list = (args, o = {}) => {
226
256
  try {
227
- return execFileSync('git', ['config', ...args, '-z', '--list', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env });
257
+ return execFileSync('git', ['config', ...args, '-z', '--list', '--show-scope', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env: { ...env, ...o.env } });
228
258
  }
229
259
  catch (err) {
230
260
  const said = String(err.stderr ?? err.message).trim().split('\n').pop() ?? '';
231
261
  if (/unable to read config file .*: No such file or directory/.test(said))
232
262
  return '';
233
- if (unread) {
234
- unread(said);
263
+ if (o.unread) {
264
+ o.unread(said);
235
265
  return '';
236
266
  }
237
- throw new UserError(`Impeccable keeps your global git settings and the files they include out of the sandbox, and git could not read them (${said}). Fix that file, or the setting that includes it, and run impeccable-reviews again.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
267
+ throw new UserError(`Impeccable keeps your git settings and the files they include out of the sandbox, and git could not read them (${said}). Fix that file, or the setting that includes it, and run impeccable-reviews again.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
238
268
  }
239
269
  };
240
- const listing = list(['--global', '--includes']);
241
- const { text, files } = scrubGitConfig(listing);
270
+ // The system scope first, so a global setting wins over the system one in the copy as it does for git.
271
+ const listing = list(['--includes'], { env: { GIT_CONFIG_GLOBAL: '/dev/null' } }) + list(['--global', '--includes']);
272
+ const { text, files, helpers } = scrubGitConfig(listing);
242
273
  const seen = new Set(files);
243
- const includes = gitIncludes(listing, home);
274
+ const includes = gitIncludes(listing, home, 'global');
244
275
  for (const file of includes) {
245
276
  if (seen.has(file))
246
277
  continue;
247
278
  seen.add(file);
248
279
  // The listing above read every include that holds for this checkout and stopped on one git cannot read, so one that
249
280
  // fails here holds for another checkout only.
250
- includes.push(...gitIncludes(list(['--file', file, '--no-includes'], (said) => log.write('git include not read', { file, said })), home));
281
+ includes.push(...gitIncludes(list(['--file', file, '--no-includes'], { unread: (said) => log.write('git include not read', { file, said }) }), home));
251
282
  }
252
283
  const path = join(ownFolder(), `gitconfig-${randomBytes(6).toString('hex')}`);
253
284
  writeFileSync(path, text, { mode: 0o600 });
254
- return { path, files: [...seen] };
285
+ const execPath = execFileSync('git', ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env }).trim();
286
+ const programs = helpers.flatMap((name) => [...new Set([execPath, ...APPLE_GIT_CORE])].map((dir) => join(dir, `git-credential-${name}`))).filter(present);
287
+ return { path, files: [...seen], helpers: programs };
255
288
  }
256
289
  /** Whether `path` is there, a dangling symlink too. */
257
290
  function present(path) {
@@ -379,12 +412,23 @@ export function allowedDomains(s) {
379
412
  names.push(...(HOST_APIS[agreed] ?? []));
380
413
  return [...new Set(names)];
381
414
  }
415
+ /**
416
+ * Nothing that asks for a password, a passphrase or a host's key, on the terminal, in a window or in Git Credential Manager's:
417
+ * the fence's own reads of the remote run with it, and so does every command in the sandbox, where a `git ls-remote` of a
418
+ * repository git has no sign-in for would otherwise wait on a prompt in the person's terminal, where the screen is drawing.
419
+ */
420
+ export const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never' };
382
421
  /**
383
422
  * What a command in the sandbox also gets: Node's fetch through the sandbox's proxy; no browser downloads, which land in
384
- * caches it does not write, from hosts it does not reach; and no git hooks installed by husky, which writes the git config
385
- * the sandbox keeps.
423
+ * caches it does not write, from hosts it does not reach; no git hooks installed by husky, which writes the git config
424
+ * the sandbox keeps; no prompt for a sign-in; and no system git config, which the sandbox lets git read (Apple's sits
425
+ * under Xcode or the Command Line Tools, not in a denied folder) and which names the osxkeychain credential helper there,
426
+ * so that git in the sandbox has no credential helper at all, as it has none from the global config: it asks for no
427
+ * sign-in rather than fail in a helper. What keeps the sign-in from a script is the denied keychain folder, which holds
428
+ * for a script that unsets this variable too (the login keychain, where git's helper and gh keep their items, is a file
429
+ * the asking process opens, so the helper and `security` find no item); this variable takes the asking away. The kept settings come through the scrubbed copy.
386
430
  */
387
- const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
431
+ const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0', GIT_CONFIG_NOSYSTEM: '1', ...NO_PROMPT };
388
432
  /** And, for the agent's shell, npm's cache and corepack's package managers in the run's scratch folder: what a sandboxed install fetched stays with the run, and ~/.npm, which the person's npm and npx read from, is never written. */
389
433
  const scratchCaches = (scratch) => ({ npm_config_cache: join(scratch, 'npm-cache'), COREPACK_HOME: join(scratch, 'corepack'), COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' });
390
434
  /**
@@ -468,7 +512,7 @@ export async function startSandbox(o) {
468
512
  }
469
513
  })();
470
514
  const global = scrubbedGitConfig(git.root, home, o.log);
471
- const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: global.files });
515
+ const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: [...global.files, ...global.helpers] });
472
516
  const files = checkoutFiles(git.root);
473
517
  const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
474
518
  let domains = allowedDomains({ ...sources, hosts: o.hosts() });
@@ -562,7 +606,7 @@ export async function startSandbox(o) {
562
606
  const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
563
607
  running++;
564
608
  try {
565
- // Git in the sandbox reads the scrubbed copy of the global settings: the person's own are denied, and git dies on a global config it cannot read.
609
+ // Git in the sandbox reads the scrubbed copy of the system and global settings: the person's own global files are denied, the system ones turned off (INSIDE), and git dies on a global config it cannot read.
566
610
  return await exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE, ...scratchCaches(o.scratch), ...pnpmEnvironment(home, platform), GIT_CONFIG_GLOBAL: global.path } });
567
611
  }
568
612
  finally {
package/dist/ui/look.js CHANGED
@@ -63,7 +63,7 @@ function block(lines, width, size = Math.max(0, ...lines.map((line) => visibleWi
63
63
  const left = ' '.repeat(Math.max(0, Math.floor((width - Math.min(size, width)) / 2)));
64
64
  return lines.map((line) => left + truncateToWidth(line, width - left.length, '…'));
65
65
  }
66
- /** Plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
66
+ /** A paragraph of plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
67
67
  function balanced(text, max) {
68
68
  const sentences = text.split(/(?<=[.!?])\s+/);
69
69
  if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
@@ -76,8 +76,8 @@ function balanced(text, max) {
76
76
  }
77
77
  return lines;
78
78
  }
79
- /** Supporting text: wrapped, soft, each line centered. */
80
- const said = (text, inner, st) => balanced(text, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner));
79
+ /** Supporting text: wrapped, soft, each line centered; a blank line in it parts paragraphs, each wrapped on its own and an empty row between. */
80
+ const said = (text, inner, st) => text.split('\n\n').flatMap((part, i) => [...(i ? [''] : []), ...balanced(part, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner))]);
81
81
  /** Wrapped text whose first line starts with `first` and the rest line up under it. */
82
82
  const hang = (text, width, first) => wrapTextWithAnsi(text, Math.max(10, width - first.length)).map((line, i) => (i ? ' '.repeat(first.length) : first) + line);
83
83
  /** An address cut into rows no wider than `width`, after a slash where one falls in the row's last two thirds, each linking to the whole of it, so any row opens it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,