@impeccable/reviews 0.1.10 → 0.1.12
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 +2 -2
- package/dist/fence.js +37 -2
- package/dist/host-questions.js +7 -7
- package/dist/sandbox.js +82 -38
- package/dist/ui/look.js +3 -3
- package/package.json +1 -1
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/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 {
|
|
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
|
package/dist/host-questions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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' ?
|
|
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
|
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
|
|
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
|
|
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 +
|
|
165
|
-
const origin = parts[i], entry = parts[i +
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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 (
|
|
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
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
241
|
-
const
|
|
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
|
-
|
|
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;
|
|
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
|
-
/**
|
|
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(
|
|
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. */
|