@impeccable/reviews 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +32 -17
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +8 -7
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +9 -6
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +397 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Sets a repository up for Impeccable for PRs from your terminal. Run it in a checkout of the repository: it signs you in through the browser, then a setup agent reads the checkout, runs what it needs, saves the settings through Impeccable's setup API, asks you only what it cannot find out, opens a test drive pull request for Impeccable to review (and a setup pull request to merge, when the repository needs one) and writes a setup report. A web page mirrors the run as it goes and follows the review.
|
|
4
4
|
|
|
5
|
-
You need Node 22.19 or newer, git, and the `gh` command signed in to GitHub as someone who can push branches to the repository (the agent opens the pull request as you). Right after sign-in the command asks GitHub with a dry run of a push, which creates nothing, and when git cannot push it says which account git signs in as and stops before anything is made, unless you fix it and try again. On Linux
|
|
5
|
+
You need Node 22.19 or newer, git, and the `gh` command signed in to GitHub as someone who can push branches to the repository (the agent opens the pull request as you). Right after sign-in the command asks GitHub with a dry run of a push, which creates nothing, and when git cannot push it says which account git signs in as and stops before anything is made, unless you fix it and try again. On Linux the agent's commands run in a sandbox through `bubblewrap`, `socat` and `ripgrep`, which the command needs installed.
|
|
6
6
|
|
|
7
7
|
## Run it
|
|
8
8
|
|
|
@@ -15,7 +15,7 @@ npx @impeccable/reviews
|
|
|
15
15
|
|
|
16
16
|
`npm install -g @impeccable/reviews` puts the `impeccable-reviews` command on your PATH instead.
|
|
17
17
|
|
|
18
|
-
Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the test drive, the setup pull request, the web page and what is left for you.
|
|
18
|
+
Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
|
|
19
19
|
|
|
20
20
|
Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (every question can be skipped); on the run screen, Esc twice (or Ctrl+C) stops the run. A screen taller than the terminal scrolls with ↑↓ and PgUp PgDn (↑↓ still move among a question's choices). The screens are black and white, with emphasis by intensity: greys on a terminal that takes true colour and reports its background, bold and dim elsewhere, and under `NO_COLOR`. The mark at the top is the logo's own image in a terminal that shows images (iTerm2, kitty, Ghostty, WezTerm; never under tmux), a square with its slash beside the letter-spaced wordmark elsewhere, and plain text on a dumb terminal.
|
|
21
21
|
|
|
@@ -30,18 +30,19 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
|
|
|
30
30
|
| `--yes` | off | Runs the allowlisted commands without asking, which they already do; anything off the list is still refused. |
|
|
31
31
|
| `--debug` | off | Writes a debug log and a transcript of the run (`transcript-<run>.md`, with its raw events in `transcript-<run>.jsonl`) to the temp folder, with tokens and whatever is shaped like a secret removed, and prints where they are. |
|
|
32
32
|
| `--transcript <path>` | the temp folder | Writes the transcript there instead: a folder, or a `.md` file with the `.jsonl` beside it. |
|
|
33
|
-
| `--scan-report` | off | Writes what the fence's content checks found (`scan-report-<run>.json`: each match's check, where it was, the tool and what it did, never the text, with counts per check) beside the transcript, and prints where it is. |
|
|
33
|
+
| `--scan-report` | off | Writes what the fence's content checks found, a status line refused for a secret's shape among them (`scan-report-<run>.json`: each match's check, where it was, the tool and what it did, never the text, with counts per check) beside the transcript, and prints where it is. |
|
|
34
34
|
| `--frames <dir>` | off | Writes each screen into the folder as it changes (`NNN-<state>.ansi` with its styling, `NNN-<state>.txt`, and `index.json`), for the eval. |
|
|
35
|
+
| `--no-sandbox` | off | Runs the setup's commands without a sandbox, with all your access, where the command would otherwise stop: on Linux without `bubblewrap`, `socat` and `ripgrep`, and on other systems. The first screen says so once, and the local preview check does not run. |
|
|
35
36
|
| `--no-telemetry` | off | Reports none of the command's steps to Impeccable (see What it reports). `DO_NOT_TRACK=1` does the same. |
|
|
36
37
|
|
|
37
38
|
## What happens
|
|
38
39
|
|
|
39
|
-
1. **Sign in.** The consent page names this repository, read from the checkout's `origin` remote; you approve, and it answers a small server on the first free port of 8239, 8238, 8240, 8237, 8236 and 8235, with PKCE and a state check, then sends the browser on to the run's web page. The server holds its port on both loopback addresses (`127.0.0.1` and `::1`), since a browser takes `localhost` for either: a port another program holds on one of them counts as taken. On a remote shell, press P and paste the address the browser ends on.
|
|
40
|
+
1. **Sign in.** The consent page names this repository, read from the checkout's `origin` remote; you approve, and it answers a small server on the first free port of 8239, 8238, 8240, 8237, 8236 and 8235, with PKCE and a state check, then sends the browser on to the run's web page. The server holds its port on both loopback addresses (`127.0.0.1` and `::1`), since a browser takes `localhost` for either: a port another program holds on one of them counts as taken. On a remote shell, press P and paste the address the browser ends on. The sign-in trusts the programs running on this machine: the link, with its state, is in the command that opens your browser, and on the clipboard once you press C, so a program that reads it can answer the server first and spoil the sign-in, which you then run again; the server takes only that first answer, and the command uses only a token for this checkout's repository whose addresses are on `--host`.
|
|
40
41
|
2. **A web front end?** Impeccable reviews web front ends, mobile web included. A checkout with none (a React Native, Expo or Flutter app without a web target, or a service with no page, template, stylesheet or web framework) ends here: the terminal and the web page say "Impeccable reviews web front ends, and this repository does not have one. Nothing was set up.", and it exits 2. When the checkout cannot tell, Impeccable's own scan decides. A checkout with a web front end that the scan skipped stops the same way, with the setup page, where the person chooses Add anyway before running it again.
|
|
41
|
-
3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through Cloudflare's CLI, cf, when you are signed in to it here, read only), and once that page is your app it takes the host's previews as they are, with no question and no setup pull request. When the repository is set up for Vercel or Netlify but that host has never previewed it, it asks you for one thing, to sign in to the host,
|
|
42
|
+
3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through Cloudflare's CLI, cf, when you are signed in to it here, read only), and once that page is your app it takes the host's previews as they are, with no question and no setup pull request. When the repository is set up for Vercel or Netlify but that host has never previewed it, it asks you for one thing, on one screen: to sign in to the host, Sign in or Skip, the login in your browser on Enter, and a retry when it did not complete (two failed retries count as a skip); when the host's CLI on this machine is signed in already, the same screen asks whether to set the host up with that sign-in, Set up or Skip, and runs no login. Nothing changes on the host before your yes there. Then it does the rest itself. On Vercel it creates the project with `vercel project add`, named after the folder, in the team the CLI's current scope names (asking which, only when you have several and none is current), links the checkout to it with `vercel link`, connects the repository (only to a project the run created that way, or one connected to the repository already: `vercel link` links an existing project of the folder's name, so one of yours that the run did not create is left as it is, and the setup ends there; a `.vercel/project.json` that is not what the run's own `vercel link` wrote, or names a project Vercel no longer has, also ends it and keeps the test drive's push held, by design, since releasing it on a state the run cannot verify could make the real project's first deployment public: remove or relink the file), puts Vercel's login in front of every address of the new project before pushing anything (Vercel makes a new project's first deployment production, whatever branch it builds, and would serve the test drive at the project's own address otherwise; the report says how to open production once your default branch is live), pushes the test drive and waits up to five minutes for Vercel's build of it (the task in progress reads "waiting for Vercel's preview · 1:05", counting up), proves that preview, takes Vercel's previews as they are and only then opens the test drive's pull request, so its review renders; a new project starts with Vercel Authentication on, which the run gets past with a bypass secret on your yes. On Netlify it creates the site in your Netlify team (asking which, only when you have several), connects the repository through Netlify's site settings and Netlify's GitHub app, so pull requests get deploy previews (only the site the run created, or one built from the repository already: a site of yours built from another repository, or one the run did not create, is never pointed at it), pushes the test drive and opens it as a draft, which Impeccable leaves unreviewed, waits for its deploy preview, proves it, takes Netlify's previews as they are and marks the draft ready. When the host's GitHub app cannot reach the repository, one more screen opens the app's page on GitHub for you to give it access, and the run waits. On skip it builds Impeccable's own previews as usual.
|
|
42
43
|
4. **Two pull requests,** opened as you with `gh`, even when something is left for a person, which goes into the report, both into the default branch and in this order:
|
|
43
44
|
- the test drive, safe to close (`impeccable/test-drive`, "Impeccable test drive (safe to close)"), always: a few real style or layout changes on a page the preview renders, on top of the setup commit when there is one, so it carries the workflow, whose job builds the preview on the pull request while Impeccable's review waits for it. Its pull request opens only once its preview is proven: the host's build of the pushed branch, or the local proof of Impeccable's own preview. No preview, no test drive: the run then says why in the report and what you can do, and still opens the setup pull request when the repository needs one;
|
|
44
|
-
- the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed.
|
|
45
|
+
- the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed. The report lists the files it changes, so one the setup never meant to change stands out.
|
|
45
46
|
5. **The end.** The run ends once the pull requests are open and recorded. The report (what it found, configured, skipped and could not verify, and what to do before merging) goes to the web page, which follows Impeccable's review of the test drive; the terminal ends on the test drive's screen (when there is a setup pull request too), then "You're all set up" with the setup pull request, or the test drive when there is none, and what is left, and its summary keeps both pull requests and the web page; exit 0. If something stops it, it says why in a sentence and exits 1.
|
|
46
47
|
|
|
47
48
|
The sign-in's setup token lasts an hour. A run that goes longer asks you to sign in again, in the same terminal, and carries on.
|
|
@@ -50,25 +51,25 @@ The sign-in's setup token lasts an hour. A run that goes longer asks you to sign
|
|
|
50
51
|
|
|
51
52
|
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:
|
|
52
53
|
|
|
53
|
-
- **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.
|
|
54
|
-
- **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); the run's scratch folder; and the package managers' caches (
|
|
55
|
-
- **Network**: only the hosts this setup needs: `--host`; GitHub (`github.com`,
|
|
54
|
+
- **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
|
+
- **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.
|
|
56
|
+
- **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.
|
|
56
57
|
|
|
57
|
-
What acts as you runs outside the sandbox, as a command of its own: `git commit`, `git push`, `gh`, `vercel`, `netlify` and the install of a host's CLI need your sign-ins, keys and keychain, which the sandbox never opens, and run none of the repository's code. Git's hooks are off for them, and for every git the command runs itself, and git is pointed at this checkout's own git folders. A command that joins one of them to another program is refused, and the agent runs them apart; `cd` and git's other commands may come along. What the command fetches there itself, a host's CLI or Cloudflare's `cf` through `npx`, it fetches from a folder of its own, so the repository's `.npmrc` and `node_modules`
|
|
58
|
+
What acts as you runs outside the sandbox, as a command of its own: `git commit`, `git push`, `gh`, `vercel`, `netlify` and the install of a host's CLI need your sign-ins, keys and keychain, which the sandbox never opens, and run none of the repository's code. Git's hooks and replacement objects are off for them, and for every git the command runs itself, so what git reads of a commit is what a push sends, and git is pointed at this checkout's own git folders. A command that joins one of them to another program is refused, and the agent runs them apart; `cd` and git's other commands may come along. What the command fetches there itself, a host's CLI or Cloudflare's `cf` through `npx`, it fetches from a folder of its own, through an npm cache of its own and asking the registry afresh, so neither the repository's `.npmrc` and `node_modules` nor a cache a sandboxed install wrote choose what runs as you; each is pinned to the version this release names. On macOS the local preview check runs the app's commands in a sandbox of its own: they write only in its throwaway copy, read and reach what the agent's commands do, and serve the app on this machine, where the check requests it. On Linux bubblewrap gives each command a network of its own, which the check could not request, so there the check does not run: rather than run the repository's build with all your access, it tells the agent so, which saves the build the repository names and leaves the proof to the preview job on the pull request; the first screen says so too.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
The command stops when the sandbox does not start: on macOS inside another tool's sandbox, on Linux without `bubblewrap`, `socat` and `ripgrep`, which the sentence that stops it names, and on other systems, which have none. It stops too when git cannot read your global settings or a file they include for this checkout (a bad line, a file it may not open), with git's words, since what that file includes in turn would stay readable: fix it and run again. A file git cannot read that an include holds for another checkout only stays denied and is named in the debug log, and the run goes on, though what that file includes in turn is not denied; a file an include names that is not there is no news. Only `--no-sandbox` runs the commands without one, with all your access, which the first screen says once. On Linux bubblewrap takes no pattern in a write rule and denies an absent path by putting an empty file in its place, which git reads as nothing for `config.worktree`, `hooks`, `info` and `modules` but dies on for a `commondir`: so a command there can make a `commondir` or a `.git` below the checkout's top. Git outside the sandbox never reads them during the run, being pointed at the checkout's own folders, and at the end of the run the command removes what appeared there and says so in the summary.
|
|
60
61
|
|
|
61
|
-
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. It also reads what goes past it: text shaped like a secret (a cloud or
|
|
62
|
+
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.
|
|
62
63
|
|
|
63
|
-
Without asking: `git status|diff|log|show|add|commit|checkout -b|push -u origin <branch>`, `gh pr create
|
|
64
|
+
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.
|
|
64
65
|
|
|
65
|
-
`vercel whoami` and `vercel --version`. Once you
|
|
66
|
+
`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.
|
|
66
67
|
|
|
67
|
-
`netlify --version` and `netlify status`. Once you
|
|
68
|
+
`netlify --version` and `netlify status`. Once you said yes to the Netlify setup: `netlify link --git-remote-url` naming this repository, which finds a site built from it (never a site by `--name` or `--id`), and `netlify sites:create`, in your own current Netlify team, or with `--account-slug` for the one team you picked when the run asked, and a global install of the Netlify CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g netlify-cli@<version>`; another package manager, version or tag is refused). The sign-in (`netlify login`), the repository's connection (the site's settings, through Netlify's API, for a site the run created or one built from the repository already, never one of yours built from another repository) and the site's password (`netlify api` prints a site's settings, the password among them) are the run's own tools, never the agent's commands; `netlify init` never runs. Never a deploy (`netlify deploy --build` builds the repository with your Netlify sign-in, and the two never run together), a site's removal, its environment variables, or a token on the command line.
|
|
68
69
|
|
|
69
70
|
Scratch output, such as the log of a server the agent starts, goes to a folder in the OS temp directory, never into the checkout; a `>` redirect into that run's folder is the one write outside the checkout that runs, and the agent reads what it wrote there back (the path with its symlinks resolved).
|
|
70
71
|
|
|
71
|
-
Refused, never asked, with the reason going back to the agent, which finds another way or leaves the step to you in the report: anything else (`node -e` too), a read outside the checkout (symlinks followed), a read of a `.env` file, a recursive `grep` without `--exclude=.env*`, a push to the default branch, a global install, publishing, a script whose name says it publishes, releases or deploys, a setting such as `PATH`, `EDITOR` or `NODE_OPTIONS` that changes what runs, a `cd` that may not run (after `&&` or `||`, or in a pipe) or goes into a folder that is not there, a command that joins `git commit` or `push`, `gh`, `vercel`, `netlify` or a host CLI's install to another program, git patterns that can match a `.env` file, a `git add` of every change or of a folder without `':(exclude,glob)**/.env*'`, a commit message or
|
|
72
|
+
Refused, never asked, with the reason going back to the agent, which finds another way or leaves the step to you in the report: anything else (`node -e` too), a read outside the checkout (symlinks followed), a read of a `.env` file (by its name, or through a symlink to it), a recursive `grep` without `--exclude=.env*`, or with `--include` or `--exclude-from`, which can let a `.env` file back in, a `git diff`, `show` or `log -p` that prints changes without ending with `-- . ':(exclude,glob)**/.env*'` (from the top of the checkout, or naming paths in the folder it runs in), where only a `git diff` of the working tree against the index may name the files after `--` instead, files in the index too (a patch shows a `.env` file the repository tracks, and your local values in one it tracks as a placeholder, and a path that is a file today may have been a folder holding a `.env` in an earlier commit, so `git diff <commit>`, `--cached`, `show` and `log -p` take the exclusion even for a file), an option in front of git's command other than `--no-pager` (`-c`, `-C`, `--git-dir`…), a push to the default branch, by any name for it (`refs/heads/main` too), a global install, publishing, a script whose name says it publishes, releases or deploys, a setting such as `PATH`, `EDITOR` or `NODE_OPTIONS` that changes what runs, a `cd` that may not run (after `&&` or `||`, or in a pipe) or goes into a folder that is not there, a command that joins `git commit` or `push`, `gh`, `vercel`, `netlify` or a host CLI's install to another program, git patterns that can match a `.env` file, a `git add` of every change or of a folder without `':(exclude,glob)**/.env*'`, a commit message, template or path list read from a file (`-F`, `-t`, `--pathspec-from-file`), a `commit -a`, a pull request body read from a file (`--body-file`, `--template`), a commit that carries a file the agent never wrote or named, or the preview workflow otherwise than `impeccable_sync_workflow` wrote it, a push in the same command as the commit or `git checkout -b` that makes what it carries, a commit that reuses the message of a `.env` file or of a commit named from outside the checkout, package scripts and the tools on the list once the agent has changed a `package.json` or a file they load (a `*.config.*` or `.*rc` file, `tsconfig*.json`, anything under `node_modules`…; after `vite.config.*`, whose preview hosts the setup may correct, the local preview check still runs), or once a package manager's command rewrote one (`.vercel/project.json` too; `node_modules`, which an install writes, and `tsconfig.json`, where `next build` writes the options it needs, aside), and, past 400 tool calls in a run, any but the progress update and the report. `rm -rf`, force pushes, `git reset --hard`, `sudo`, database commands that throw data away (`prisma migrate reset`, `db push --force-reset`, `sequelize db:drop`…), other writes outside the checkout and writes into a `.git`, the checkout's or one below its top, never run.
|
|
72
73
|
|
|
73
74
|
## What it reports
|
|
74
75
|
|
|
@@ -80,14 +81,14 @@ Each report carries the step, how it ended, when, the command's version, Node's
|
|
|
80
81
|
|
|
81
82
|
The setup token and the run token stay in memory. The agent never sees them: the tools add them to their own requests. The debug log and the transcript remove them.
|
|
82
83
|
|
|
83
|
-
The debug log and the transcript also remove whatever is shaped like a secret, wherever the agent wrote or read it: a cloud or
|
|
84
|
+
The debug log and the transcript also remove whatever is shaped like a secret, wherever the agent wrote or read it: a cloud, GitHub, Google or SendGrid key, a token, a Slack webhook's address, a private key through its end, a password in an address (`postgres://app:…@db`) or on a curl command line (`curl -u user:…`), whose address and user stay, and a random value under a secret-like name (`API_TOKEN=…`), whose name stays. So a call the fence refused for holding a secret leaves no copy of it there.
|
|
84
85
|
|
|
85
86
|
A test account's password, typed hidden on the account's screen (after a screen that says why the run asks), goes from there to Impeccable's test-accounts setting: the agent only holds a reference to it, and the debug log and the transcript remove it.
|
|
86
87
|
|
|
87
88
|
What you type into any other question the agent asks, it reads. So a question whose title, prompt or hint names a token, a password, a secret, a key or a credential is typed hidden however the agent asked it, and the agent gets a reference, as for a test account's password. Every other question with a field says under it "Never enter a password, a token or a key here."
|
|
88
89
|
|
|
89
|
-
Vercel's bypass secret, which lets reviews open previews behind Vercel's login, is handled the same way. When Vercel's login stands in front of a preview, the command asks you once, on one screen, whether to create a bypass secret for Impeccable, and on your yes creates it as you through the Vercel CLI, labelled "Impeccable reviews" (`vercel api` for Vercel's protection-bypass route; an older CLI's `vercel project protection enable --protection-bypass`), then reads it back from the project's settings. A project that already holds a secret with that label gives it without a question. When the
|
|
90
|
+
Vercel's bypass secret, which lets reviews open previews behind Vercel's login, is handled the same way. The secret goes only to a preview the linked project serves: before anything is sent, the command reads the project this folder is linked to (`.vercel/project.json`, whose ids it takes only in Vercel's own form; once the run's own `vercel link` wrote that file, only what it wrote counts) through the Vercel CLI as you, and refuses, saying so, unless Vercel shows that project connected to this repository, with Deployment Protection in its settings, and the preview's deployment as the project's own (among its latest, or read on its own by its address), since a deployment GitHub lists can name any address and its page proves nothing about whose it is, or whether the login on it is Vercel's: a deployment can serve a page shaped like Vercel's login, and only your project's settings say whether Vercel's stands there. The same read decides which project the setup locks before its first deployment. When Vercel's login stands in front of a preview of that project, the command asks you once, on one screen that names the preview's address, whether to create a bypass secret for Impeccable, and on your yes creates it as you through the Vercel CLI, labelled "Impeccable reviews" (`vercel api` for Vercel's protection-bypass route; an older CLI's `vercel project protection enable --protection-bypass`), then reads it back from the project's settings. A project that already holds a secret with that label gives it without a question. When your role cannot read or make one, a screen names the settings page and the preview's address, and takes the secret you type, hidden; a folder that is not linked, or a CLI that is signed out, gets no such screen, since nothing then shows the preview to be yours. It opens the preview with the secret first and saves it, with the setting that sends it in the `x-vercel-protection-bypass` header, only once the page shows your app. The agent never holds it: the secret goes from the command's own process to Vercel and to Impeccable's setting, and the debug log and the transcript remove it. Vercel documents this as Protection Bypass for Automation, which every plan includes; you revoke it in the project's Settings, Deployment Protection.
|
|
90
91
|
|
|
91
|
-
Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you. For a password Netlify gives back, it asks you once, on one screen, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back,
|
|
92
|
+
Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you, and that read is what shows the site to be yours: a site the CLI cannot read as you (it is signed out, or the site is in none of your teams) gets no password, and the command refuses, saying so, since the lock at a listed address may be anyone's. For a password Netlify gives back, it asks you once, on one screen that names the preview's address, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back, a screen names the address and takes the password you type, hidden (the run never sets one itself: the Netlify CLI takes a new password only on its command line, where any process could read it). It opens the preview with the password first, as basic credentials in the `Authorization` header (Netlify's password page asks for a password alone, so the credentials carry no username), and saves it, with the setting that sends that header, only once the page shows your app; a password that opens the preview on Netlify's own form but not in the header is reported, not saved, since reviews can send only a header. A site behind Netlify's team login (a private site) has no way in for tools, so the command asks you once, on one screen, whether to make the site public, and on your yes makes it public as you through the Netlify CLI (`netlify api updateSite` with `sso_login` off); anyone with its address can then open it, the live site included, until you make it private again in Project configuration, General, Visitor access. Where Netlify refuses (your team's default keeps every project private, or your role cannot change it), the command opens that page in your browser and asks you to try again once you changed it there; a password you set there instead is stored as above. The agent never holds the password: it goes from the command's own process to Netlify and to Impeccable's setting, and the debug log and the transcript remove it.
|
|
92
93
|
|
|
93
|
-
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list). When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. Only where that sign-in cannot create the token (it grants no Access, cf does not run here, or the application runs on policies of its own, which cf has no command for) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen takes a client id and secret you paste, the secret hidden. It opens the preview with the token in the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers first and saves it, with the setting that sends them, only once the page shows your app; a token it made that opens nothing is deleted, with its policy. The agent never holds either, and the debug log and the transcript remove both. You revoke it in Zero Trust, Access controls, Service credentials, Service Tokens.
|
|
94
|
+
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen that names the preview's address, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list). The token goes only to a preview an Access application in your accounts covers, which that read shows; where none covers it, the command refuses, saying so, and asks for no token. When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. Only where that sign-in cannot create the token (it grants no Access, cf does not run here, or the application runs on policies of its own, which cf has no command for) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen names the address and takes a client id and secret you paste, the secret hidden, again only once an application of yours was read to cover the preview (on a skip, through cf as it is signed in here, with no sign-in opened). It opens the preview with the token in the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers first and saves it, with the setting that sends them, only once the page shows your app; a token it made that opens nothing is deleted, with its policy. The agent never holds either, and the debug log and the transcript remove both. You revoke it in Zero Trust, Access controls, Service credentials, Service Tokens.
|
package/dist/agent.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, defineTool, ModelRuntime, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import { shellEnvironment } from './fence.js';
|
|
4
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createEditToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, ModelRuntime, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { guardedFindTool, guardedGrepTool, shellEnvironment } from './fence.js';
|
|
6
6
|
import { createTranscript } from './transcript.js';
|
|
7
7
|
import { reportStatus } from './ui/bridge.js';
|
|
8
8
|
import { statusFromEvent } from './ui/run-state.js';
|
|
@@ -110,8 +110,13 @@ export async function runAgent(opts) {
|
|
|
110
110
|
model,
|
|
111
111
|
thinkingLevel: opts.effort,
|
|
112
112
|
tools: [...BUILT_IN_TOOLS, ...opts.tools.map((t) => t.name)],
|
|
113
|
-
// pi's own bash, whose commands run in the sandbox (or as the person, outside it) without the person's credentials in their environment
|
|
114
|
-
|
|
113
|
+
// pi's own bash, whose commands run in the sandbox (or as the person, outside it) without the person's credentials in their environment,
|
|
114
|
+
// and pi's own read, write, edit, grep, find and ls, which open files through the fence's operations.
|
|
115
|
+
customTools: [
|
|
116
|
+
defineTool(createBashToolDefinition(cwd, { operations: opts.bash, spawnHook: (spawn) => ({ ...spawn, env: shellEnvironment(spawn.env) }) })),
|
|
117
|
+
...(opts.files ? [defineTool(createReadToolDefinition(cwd, { operations: opts.files.read })), defineTool(createWriteToolDefinition(cwd, { operations: opts.files.write })), defineTool(createEditToolDefinition(cwd, { operations: opts.files.edit })), defineTool(guardedGrepTool(cwd, opts.files.grep)), defineTool(guardedFindTool(cwd, opts.files.find)), defineTool(createLsToolDefinition(cwd, { operations: opts.files.ls }))] : []),
|
|
118
|
+
...opts.tools,
|
|
119
|
+
],
|
|
115
120
|
});
|
|
116
121
|
return { ...created, services, diagnostics: services.diagnostics };
|
|
117
122
|
};
|
|
@@ -94,13 +94,14 @@ function throughCf(cf, accounts, signal) {
|
|
|
94
94
|
},
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
|
+
/** A sign-in that can read and change the account's Access. */
|
|
98
|
+
const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.every((scope) => s.scopes.includes(scope)));
|
|
97
99
|
/**
|
|
98
100
|
* cf signed in with the scopes, as it is or after Cloudflare's sign-in in the browser: one that did not complete offers
|
|
99
101
|
* a retry, and two failed retries end it. The new sign-in asks for the scopes it held as well, so whatever else the
|
|
100
102
|
* person uses cf for goes on working.
|
|
101
103
|
*/
|
|
102
104
|
async function reach(deps) {
|
|
103
|
-
const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.every((scope) => s.scopes.includes(scope)));
|
|
104
105
|
const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
|
|
105
106
|
let status = await cfStatus(deps.cf, deps.signal);
|
|
106
107
|
if (granted(status))
|
|
@@ -146,65 +147,73 @@ function settingsOf(app) {
|
|
|
146
147
|
}
|
|
147
148
|
/** What undoing answers when Cloudflare refused a step of it: the person removes the rest. */
|
|
148
149
|
const LEFT_BEHIND = ` Some of what the run made could not be taken back: delete the service token and the policy named "${TOKEN_NAME}" in Zero Trust.`;
|
|
150
|
+
/** Why a step stopped; through cf a 401 or a 403 is the API token's to try, unless something the run made was left behind. */
|
|
151
|
+
const stop = (way, reply, none, left = '') => {
|
|
152
|
+
const why = `${refusal(way, reply, none)}${left}`;
|
|
153
|
+
return way.cf && !left && !reply.ok && (reply.status === 401 || reply.status === 403) ? { fallback: why } : { reason: why };
|
|
154
|
+
};
|
|
155
|
+
/** Every page of a list, as many as it takes: a page shorter than a full one is the last. */
|
|
156
|
+
async function every(api, path, per) {
|
|
157
|
+
const items = [];
|
|
158
|
+
for (let page = 1; page <= 20; page++) {
|
|
159
|
+
const reply = await api('GET', `${path}?per_page=${per}&page=${page}`);
|
|
160
|
+
if (!reply.ok)
|
|
161
|
+
return reply;
|
|
162
|
+
const batch = Array.isArray(reply.result) ? reply.result : [];
|
|
163
|
+
items.push(...batch);
|
|
164
|
+
if (batch.length < per)
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, result: items };
|
|
168
|
+
}
|
|
149
169
|
/**
|
|
150
|
-
*
|
|
151
|
-
* the
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* application, or a refusal of permissions once what was made is undone, answers a `fallback` for the API token. What
|
|
155
|
-
* this made is undone, last first, when a later step fails, or on `undo`, even once the run is stopped.
|
|
170
|
+
* The self-hosted Access applications whose destination covers the address, in the accounts the person reaches, as the
|
|
171
|
+
* person: the one whose policy allows the token, and what makes the address theirs, so a token goes to no other. Every
|
|
172
|
+
* account's applications are read, so two that cover it, in one account or two, are never settled by the order they
|
|
173
|
+
* came in. Through cf, a refusal of permissions answers a `fallback` for the API token.
|
|
156
174
|
*/
|
|
157
|
-
async function
|
|
158
|
-
const
|
|
159
|
-
/** Why it stopped; through cf a 401 or a 403 is the API token's to try, unless something the run made was left behind. */
|
|
160
|
-
const stop = (reply, none, left = '') => {
|
|
161
|
-
const why = `${refusal(way, reply, none)}${left}`;
|
|
162
|
-
return way.cf && !left && !reply.ok && (reply.status === 401 || reply.status === 403) ? { fallback: why } : { reason: why };
|
|
163
|
-
};
|
|
164
|
-
/** Every page of a list, as many as it takes: a page shorter than a full one is the last. */
|
|
165
|
-
const every = async (path, per) => {
|
|
166
|
-
const items = [];
|
|
167
|
-
for (let page = 1; page <= 20; page++) {
|
|
168
|
-
const reply = await api('GET', `${path}?per_page=${per}&page=${page}`);
|
|
169
|
-
if (!reply.ok)
|
|
170
|
-
return reply;
|
|
171
|
-
const batch = Array.isArray(reply.result) ? reply.result : [];
|
|
172
|
-
items.push(...batch);
|
|
173
|
-
if (batch.length < per)
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
return { ok: true, result: items };
|
|
177
|
-
};
|
|
178
|
-
const url = new URL(deps.url);
|
|
179
|
-
const accounts = await every('/accounts', 50);
|
|
175
|
+
async function covering(way, url) {
|
|
176
|
+
const accounts = await every(way.call, '/accounts', 50);
|
|
180
177
|
if (!accounts.ok)
|
|
181
|
-
return stop(accounts, '');
|
|
182
|
-
|
|
183
|
-
const found = [];
|
|
178
|
+
return stop(way, accounts, '');
|
|
179
|
+
const apps = [];
|
|
184
180
|
let unread;
|
|
185
181
|
for (const account of list(accounts.result)) {
|
|
186
182
|
if (!uid(account.id))
|
|
187
183
|
continue;
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
unread =
|
|
184
|
+
const read = await every(way.call, `/accounts/${account.id}/access/apps`, 1000);
|
|
185
|
+
if (!read.ok)
|
|
186
|
+
unread = read;
|
|
191
187
|
else
|
|
192
|
-
for (const app of list(
|
|
188
|
+
for (const app of list(read.result))
|
|
193
189
|
if (app.type === 'self_hosted' && uid(app.id) && destinations(app).some((d) => covers(d, url)))
|
|
194
|
-
|
|
190
|
+
apps.push({ account: account.id, app });
|
|
195
191
|
}
|
|
192
|
+
if (!apps.length)
|
|
193
|
+
return unread ? stop(way, unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
|
|
194
|
+
return { apps };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* As the person, the way they reach Cloudflare's API: on the one application in front of the address, the service
|
|
198
|
+
* token, then a Service Auth policy that allows it. An application on reusable policies (every one the dashboard makes
|
|
199
|
+
* now) gets a reusable policy added to its list; one on policies of its own gets one of those, since the two never mix
|
|
200
|
+
* on one application. Through cf, that application, or a refusal of permissions once what was made is undone, answers a
|
|
201
|
+
* `fallback` for the API token. What this made is undone, last first, when a later step fails, or on `undo`, even once
|
|
202
|
+
* the run is stopped.
|
|
203
|
+
*/
|
|
204
|
+
async function create(way, deps, found) {
|
|
205
|
+
const api = way.call;
|
|
206
|
+
const url = new URL(deps.url);
|
|
196
207
|
if (found.length > 1)
|
|
197
208
|
return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
|
|
198
|
-
if (!found.length)
|
|
199
|
-
return unread ? stop(unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
|
|
200
209
|
const { account, app } = found[0];
|
|
201
210
|
const base = `/accounts/${account}/access`;
|
|
202
211
|
const links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
|
|
203
212
|
let reusable = true;
|
|
204
213
|
if (links.length) {
|
|
205
|
-
const policies = await every(`${base}/policies`, 1000);
|
|
214
|
+
const policies = await every(api, `${base}/policies`, 1000);
|
|
206
215
|
if (!policies.ok)
|
|
207
|
-
return stop(policies, '');
|
|
216
|
+
return stop(way, policies, '');
|
|
208
217
|
const ids = new Set(list(policies.result).map((p) => p.id));
|
|
209
218
|
reusable = links.every((link) => ids.has(link.id));
|
|
210
219
|
}
|
|
@@ -217,7 +226,7 @@ async function create(way, deps) {
|
|
|
217
226
|
clean = (await step()).ok && clean;
|
|
218
227
|
return clean;
|
|
219
228
|
};
|
|
220
|
-
const fail = async (reply, none) => stop(reply, none, (await rollback()) ? '' : LEFT_BEHIND);
|
|
229
|
+
const fail = async (reply, none) => stop(way, reply, none, (await rollback()) ? '' : LEFT_BEHIND);
|
|
221
230
|
const made = await api('POST', `${base}/service_tokens`, { name: TOKEN_NAME });
|
|
222
231
|
const token = record(made.ok ? made.result : undefined);
|
|
223
232
|
// The secret is shown this once: the log and the transcript learn it before anything else happens.
|
|
@@ -263,10 +272,24 @@ async function admitted(token, deps) {
|
|
|
263
272
|
* Gets a service token in place for the address Cloudflare Access stands in front of, and saves only one that opens it.
|
|
264
273
|
* On the person's yes the run creates it and allows it in the application's policy, through cf signed in as them, or
|
|
265
274
|
* with an API token they give where that sign-in cannot; on a skip, or when that cannot be done or the token does not
|
|
266
|
-
* open the address (and what was made is undone), the person may paste one they made.
|
|
275
|
+
* open the address (and what was made is undone), the person may paste one they made. A token, made or pasted, goes to
|
|
276
|
+
* the address only once an Access application in the person's accounts was read to cover it: a listed preview may be
|
|
277
|
+
* anyone's, and its page says nothing about whose Access stands in front.
|
|
267
278
|
*/
|
|
268
279
|
export async function passCloudflareAccess(deps) {
|
|
269
280
|
const shut = (proof) => proof.reason ?? 'The address did not open with the service token.';
|
|
281
|
+
const url = new URL(deps.url);
|
|
282
|
+
/** An Access application the person reaches covers the address, read this call: the one binding of the address to them. */
|
|
283
|
+
let covered = false;
|
|
284
|
+
const find = async (way) => {
|
|
285
|
+
const found = await covering(way, url);
|
|
286
|
+
covered ||= 'apps' in found;
|
|
287
|
+
return found;
|
|
288
|
+
};
|
|
289
|
+
const build = async (way) => {
|
|
290
|
+
const found = await find(way);
|
|
291
|
+
return 'apps' in found ? create(way, deps, found.apps) : found;
|
|
292
|
+
};
|
|
270
293
|
/** A token that opened the address, saved for reviews. */
|
|
271
294
|
const keep = async (token, how, made = {}) => {
|
|
272
295
|
const failed = await deps.save(token);
|
|
@@ -274,11 +297,16 @@ export async function passCloudflareAccess(deps) {
|
|
|
274
297
|
};
|
|
275
298
|
/** Why the run's own way ended without a token that opens the address, for the paste screen's skip. */
|
|
276
299
|
let stopped;
|
|
277
|
-
if (!(await deps.consent()))
|
|
300
|
+
if (!(await deps.consent())) {
|
|
278
301
|
stopped = 'The person skipped creating a service token.';
|
|
302
|
+
// cf signed in here, with whatever scopes, says whether an application of the person's covers the address, since reading takes no write; a skip opens no sign-in for it.
|
|
303
|
+
const status = await cfStatus(deps.cf, deps.signal);
|
|
304
|
+
if (status.cli === 'signed-in')
|
|
305
|
+
await find(throughCf(deps.cf, status.accounts, deps.signal));
|
|
306
|
+
}
|
|
279
307
|
else {
|
|
280
308
|
const reached = await reach(deps);
|
|
281
|
-
let made = 'way' in reached ? await
|
|
309
|
+
let made = 'way' in reached ? await build(reached.way) : 'stopped' in reached ? { reason: reached.stopped } : reached;
|
|
282
310
|
let apiToken = false;
|
|
283
311
|
if ('fallback' in made) {
|
|
284
312
|
// Where cf's sign-in cannot make the token, an API token the person makes for the run can.
|
|
@@ -288,7 +316,7 @@ export async function passCloudflareAccess(deps) {
|
|
|
288
316
|
else {
|
|
289
317
|
deps.remember(key);
|
|
290
318
|
apiToken = true;
|
|
291
|
-
made = await
|
|
319
|
+
made = await build(withApiToken(key, deps));
|
|
292
320
|
}
|
|
293
321
|
}
|
|
294
322
|
if (!('token' in made))
|
|
@@ -307,6 +335,8 @@ export async function passCloudflareAccess(deps) {
|
|
|
307
335
|
}
|
|
308
336
|
// A stopped run asks nothing more.
|
|
309
337
|
deps.signal?.throwIfAborted();
|
|
338
|
+
if (!covered)
|
|
339
|
+
return { status: 'refused', reason: `${stopped} A service token goes only to a preview an Access application in the person's accounts covers, and none was read for ${url.host}, so none was asked for or sent.` };
|
|
310
340
|
const typed = await deps.paste();
|
|
311
341
|
if (!typed)
|
|
312
342
|
return { status: 'skipped', reason: stopped };
|