@impeccable/reviews 0.1.0

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.
Files changed (46) hide show
  1. package/README.md +68 -0
  2. package/dist/agent.js +183 -0
  3. package/dist/bin.js +11 -0
  4. package/dist/fence.js +1071 -0
  5. package/dist/focus.js +98 -0
  6. package/dist/host-questions.js +71 -0
  7. package/dist/hosted.js +195 -0
  8. package/dist/http.js +36 -0
  9. package/dist/log.js +38 -0
  10. package/dist/login.js +276 -0
  11. package/dist/main.js +252 -0
  12. package/dist/netlify.js +333 -0
  13. package/dist/playbook/cases/ci-tunnel.md +21 -0
  14. package/dist/playbook/cases/first-preview-failure.md +28 -0
  15. package/dist/playbook/cases/netlify-previews.md +17 -0
  16. package/dist/playbook/cases/netlify-setup.md +24 -0
  17. package/dist/playbook/cases/preview-protection.md +21 -0
  18. package/dist/playbook/cases/pull-requests.md +31 -0
  19. package/dist/playbook/cases/sign-in.md +21 -0
  20. package/dist/playbook/cases/static-site.md +16 -0
  21. package/dist/playbook/cases/vercel-previews.md +16 -0
  22. package/dist/playbook/cases/vercel-setup.md +27 -0
  23. package/dist/playbook/cases/workflow-as-person.md +23 -0
  24. package/dist/playbook/github-previews.js +101 -0
  25. package/dist/playbook/netlify-pattern.js +24 -0
  26. package/dist/playbook/netlify-previews.js +144 -0
  27. package/dist/playbook/probe-preview.js +67 -0
  28. package/dist/playbook/protection-secrets.js +15 -0
  29. package/dist/playbook/public-preview-request.js +96 -0
  30. package/dist/playbook/publication-wait.js +46 -0
  31. package/dist/playbook/vercel-protection.js +121 -0
  32. package/dist/playbook.js +98 -0
  33. package/dist/preflight.js +211 -0
  34. package/dist/stages.js +34 -0
  35. package/dist/sync.js +142 -0
  36. package/dist/tools.js +2015 -0
  37. package/dist/transcript.js +139 -0
  38. package/dist/ui/bridge.js +145 -0
  39. package/dist/ui/frames.js +62 -0
  40. package/dist/ui/look.js +368 -0
  41. package/dist/ui/run-state.js +91 -0
  42. package/dist/ui/screens.js +544 -0
  43. package/dist/ui/sensitive.js +14 -0
  44. package/dist/vercel-bypass.js +136 -0
  45. package/dist/vercel.js +70 -0
  46. package/package.json +39 -0
package/dist/fence.js ADDED
@@ -0,0 +1,1071 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
4
+ const deny = (reason) => ({ verdict: 'deny', reason });
5
+ const never = (reason) => ({ verdict: 'never', reason });
6
+ /**
7
+ * A command line as simple commands, or null when it uses shell syntax the
8
+ * fence does not read: expansions, substitutions, subshells, brace expansion,
9
+ * here-documents, background jobs, an unclosed quote.
10
+ */
11
+ export function parseCommand(command) {
12
+ const out = [];
13
+ let words = [];
14
+ let redirects = [];
15
+ let word = '';
16
+ let inWord = false;
17
+ let redirect = null;
18
+ const endWord = () => {
19
+ if (!inWord)
20
+ return;
21
+ if (redirect)
22
+ redirects.push({ op: redirect, target: word });
23
+ else
24
+ words.push(word);
25
+ redirect = null;
26
+ word = '';
27
+ inWord = false;
28
+ };
29
+ /** The operator before the command being read: '' at the start, then ';', '&&', '||' or '|'. */
30
+ let before = '';
31
+ const endCommand = (after = '') => {
32
+ endWord();
33
+ if (redirect)
34
+ return false;
35
+ if (words.length || redirects.length) {
36
+ out.push({ words, redirects, certain: (before === '' || before === ';') && after !== '|' });
37
+ before = after;
38
+ }
39
+ words = [];
40
+ redirects = [];
41
+ return true;
42
+ };
43
+ for (let i = 0; i < command.length; i++) {
44
+ const c = command[i];
45
+ const next = command[i + 1];
46
+ if (c === "'") {
47
+ const end = command.indexOf("'", i + 1);
48
+ if (end < 0)
49
+ return null;
50
+ word += command.slice(i + 1, end);
51
+ inWord = true;
52
+ i = end;
53
+ }
54
+ else if (c === '"') {
55
+ let j = i + 1;
56
+ for (; j < command.length && command[j] !== '"'; j++) {
57
+ const d = command[j];
58
+ if (d === '$' || d === '`')
59
+ return null;
60
+ if (d === '\\' && '"\\$`\n'.includes(command[j + 1] ?? ''))
61
+ j++;
62
+ word += command[j];
63
+ }
64
+ if (j >= command.length)
65
+ return null;
66
+ inWord = true;
67
+ i = j;
68
+ }
69
+ else if (c === '\\') {
70
+ if (next !== undefined)
71
+ word += next;
72
+ inWord = true;
73
+ i++;
74
+ }
75
+ else if ('$`(){}'.includes(c)) {
76
+ return null;
77
+ }
78
+ else if (c === '#' && !inWord) {
79
+ const newline = command.indexOf('\n', i);
80
+ if (newline < 0)
81
+ break;
82
+ i = newline - 1;
83
+ }
84
+ else if (c === ' ' || c === '\t') {
85
+ endWord();
86
+ }
87
+ else if (c === '>' || c === '<' || (c === '&' && next === '>')) {
88
+ let op;
89
+ if (c === '&') {
90
+ endWord();
91
+ op = '&>';
92
+ i++;
93
+ }
94
+ else if (inWord && /^\d+$/.test(word)) {
95
+ op = word + c;
96
+ word = '';
97
+ inWord = false;
98
+ }
99
+ else {
100
+ endWord();
101
+ op = c;
102
+ }
103
+ if (command[i + 1] === op.at(-1)) {
104
+ op += op.at(-1);
105
+ i++;
106
+ }
107
+ if (op.endsWith('<<') || command[i + 1] === '(')
108
+ return null;
109
+ const dup = /^&(\d+|-)/.exec(command.slice(i + 1));
110
+ if (dup)
111
+ i += dup[0].length; // 2>&1: a descriptor, not a file
112
+ else if (command[i + 1] === '&')
113
+ return null;
114
+ else
115
+ redirect = op;
116
+ }
117
+ else if (c === '\n' || c === ';' || c === '|' || c === '&') {
118
+ if (c === '&' && next !== '&')
119
+ return null; // a background job
120
+ const op = c === '\n' || c === ';' ? ';' : next === c ? c + c : c;
121
+ if (!endCommand(op))
122
+ return null;
123
+ if (op.length === 2)
124
+ i++;
125
+ }
126
+ else {
127
+ word += c;
128
+ inWord = true;
129
+ }
130
+ }
131
+ return endCommand() ? out : null;
132
+ }
133
+ /** The path with every symlink on it resolved; the part that does not exist yet stays as written. */
134
+ function realPath(path) {
135
+ const rest = [];
136
+ for (let at = path;;) {
137
+ try {
138
+ return join(realpathSync(at), ...rest.reverse());
139
+ }
140
+ catch {
141
+ const parent = dirname(at);
142
+ if (parent === at)
143
+ return path;
144
+ rest.push(basename(at));
145
+ at = parent;
146
+ }
147
+ }
148
+ }
149
+ const within = (path, dir) => {
150
+ const rel = relative(dir, path);
151
+ return !(rel === '..' || rel.startsWith('../') || rel.startsWith('..\\') || isAbsolute(rel));
152
+ };
153
+ /** A path in this run's scratch folder once every symlink on it is resolved, written out: no `~`, no variable, no glob. */
154
+ function inScratch(arg, where) {
155
+ if (!where.scratch || arg.startsWith('~') || /[$*?[]/.test(arg))
156
+ return false;
157
+ return within(realPath(resolve(where.cwd, arg)), where.scratch);
158
+ }
159
+ /** A redirect into this run's scratch folder; never one into the checkout, even a checkout that sits in it. */
160
+ function intoScratch(target, where) {
161
+ return inScratch(target, where) && !within(realPath(resolve(where.cwd, target)), where.root);
162
+ }
163
+ /** A path argument that leaves the checkout, through `..`, `~`, a symlink, or a `.*` glob that can match `..`. */
164
+ function outside(arg, where) {
165
+ if (arg.startsWith('~') || arg.split('/').some((part) => /^\.[*?[]/.test(part)))
166
+ return true;
167
+ return !within(realPath(resolve(where.cwd, arg)), where.root);
168
+ }
169
+ const isFolder = (path) => {
170
+ try {
171
+ return statSync(path).isDirectory();
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ };
177
+ /** Positional arguments and the values of --flag=value, which is where paths hide. */
178
+ function pathsOf(args) {
179
+ return args.flatMap((a) => (a.startsWith('--') && a.includes('=') ? [a.slice(a.indexOf('=') + 1)] : a.startsWith('-') ? [] : [a]));
180
+ }
181
+ const inside = (args, where) => !pathsOf(args).some((a) => outside(a, where));
182
+ /** A .env file, or a pattern that can name one, holds live secrets; the examples next to it do not. */
183
+ export function isSecretFile(path) {
184
+ const name = basename(path);
185
+ return name.startsWith('.env') && !/\.(example|sample|template|dist)$/.test(name);
186
+ }
187
+ function rmRecursiveForce(words) {
188
+ const at = words.indexOf('rm');
189
+ if (at < 0)
190
+ return false;
191
+ const flags = words.slice(at + 1).filter((w) => w.startsWith('-'));
192
+ const short = flags.filter((f) => !f.startsWith('--')).join('');
193
+ const recursive = /[rR]/.test(short) || flags.includes('--recursive');
194
+ const force = short.includes('f') || flags.includes('--force');
195
+ return recursive && force;
196
+ }
197
+ function forcePush(args) {
198
+ return args.some((a) => a.startsWith('--force') || (/^-[^-]/.test(a) && a.includes('f')) || (!a.startsWith('-') && a.startsWith('+')));
199
+ }
200
+ const WRITES_EVERY_ARG = new Set(['tee', 'touch', 'mkdir', 'rmdir', 'rm', 'chmod', 'chown', 'truncate']);
201
+ const WRITES_LAST_ARG = new Set(['cp', 'mv', 'ln', 'install', 'rsync', 'scp']);
202
+ /** What npx, pnpm exec, yarn and bunx may run from the checkout's own dependencies. */
203
+ const TOOLS = new Set(['tsc', 'eslint', 'prettier', 'next', 'vite', 'astro', 'nuxt', 'prisma', 'playwright', 'drizzle-kit', 'sequelize', 'knex']);
204
+ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
205
+ /** yarn's own commands that change packages, settings or the registry; `yarn <name>` runs these even when a script has the name. */
206
+ const YARN_OWN = new Set(['add', 'remove', 'upgrade', 'up', 'dlx', 'exec', 'node', 'npm', 'global', 'link', 'unlink', 'publish', 'pack', 'version', 'config', 'set', 'cache', 'init', 'create', 'import', 'patch', 'plugin', 'policies', 'workspace', 'workspaces', 'login', 'logout', 'owner', 'tag', 'team', 'access', 'rebuild', 'unplug', 'dedupe']);
207
+ /** `KEY=value` in front of a command. */
208
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
209
+ /** Variables that name a program to run, change what a program loads, or change how git, ssh and the package managers behave. */
210
+ const STEERING = /^(PATH|HOME|SHELL|IFS|ENV|BASH_ENV|CDPATH|PROMPT_COMMAND|EDITOR|VISUAL|PAGER|BROWSER|TERMINAL|\w*_EDITOR|\w*_PAGER|\w*ASKPASS\w*|LESS\w*|SSH_\w+|NODE_OPTIONS|NODE_PATH|PYTHON\w*|RUBY\w*|PERL\w*|\w*JAVA\w*_OPTIONS|LD_\w+|DYLD_\w+|GIT_\w+|NPM_CONFIG_\w+|PNPM_\w+|YARN_\w+|BUN_\w+|COREPACK_\w+)$/i;
211
+ /** What a `KEY=value` may stand in front of: the package managers and the tool runners, which build with the settings. */
212
+ const TAKES_SETTINGS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'bunx']);
213
+ /** Database commands that throw data away, in whatever database the checkout points at. */
214
+ function destroysData(tool, args) {
215
+ const sub = args.filter((a) => !a.startsWith('-')).join(' ');
216
+ if (args.includes('--force-reset') || args.includes('--accept-data-loss'))
217
+ return true;
218
+ return (tool === 'prisma' && /^(migrate reset|db execute)\b/.test(sub))
219
+ || (tool === 'drizzle-kit' && /^drop\b/.test(sub))
220
+ || (tool === 'knex' && /^migrate:(rollback|down)\b/.test(sub))
221
+ || (tool === 'sequelize' && /^db:(drop|migrate:undo|seed:undo)/.test(sub));
222
+ }
223
+ /** How many `KEY=value` words lead the command. */
224
+ function envCount(words) {
225
+ let n = 0;
226
+ while (n < words.length && ASSIGNMENT.test(words[n]))
227
+ n++;
228
+ return n;
229
+ }
230
+ /** Why a `KEY=value` in front of a command is refused: it steers what runs, its value holds shell characters, or it points out of the checkout. Null when it is a plain setting. */
231
+ function envProblem(assignment, where) {
232
+ const name = assignment.slice(0, assignment.indexOf('='));
233
+ const value = assignment.slice(name.length + 1);
234
+ if (STEERING.test(name))
235
+ return `sets ${name}, which changes what runs`;
236
+ if (/[\s;&|<>()$`\\"'*?[\]{}~!#]/.test(value))
237
+ return `a value for ${name} with shell characters in it`;
238
+ const path = value.replace(/^file:(\/\/)?/, '');
239
+ if ((path.startsWith('/') || path.split('/').includes('..')) && outside(path, where) && !inScratch(path, where))
240
+ return `a value for ${name} outside the checkout`;
241
+ return null;
242
+ }
243
+ /** The pathspec that leaves every .env file out of a `git add`, in whatever folder it sits: `:!.env*` leaves out only those beside it. */
244
+ export const EXCLUDE_ENV = ':(exclude,glob)**/.env*';
245
+ /** `git add -A` or `-u`, which stage every change in the checkout, wherever they run, or paths read from a file, which the fence does not see; git takes any start of a long option's name (--al is --all). */
246
+ const addsAll = (args) => (args.includes('--') ? args.slice(0, args.indexOf('--')) : args).some((a) => /^-[^-]*[Au]/.test(a) || (a.length > 2 && a.startsWith('--') && ['all', 'update', 'no-ignore-removal', 'pathspec-from-file'].some((o) => o.startsWith(a.slice(2).split('=')[0]))));
247
+ /** The values of git commit's options that read a file (a message, a template, pathspecs) or a commit, which may name a file (`HEAD:.env`). */
248
+ function commitReads(args) {
249
+ const values = [];
250
+ for (let i = 0; i < args.length; i++) {
251
+ const a = args[i];
252
+ if (a === '--')
253
+ break;
254
+ if (a.startsWith('--')) {
255
+ const eq = a.indexOf('=');
256
+ const name = a.slice(2, eq < 0 ? undefined : eq);
257
+ if (name === 'message' && eq < 0)
258
+ i++;
259
+ else if (name && ['file', 'template', 'reuse-message', 'reedit-message', 'pathspec-from-file'].some((o) => o.startsWith(name)))
260
+ values.push(eq < 0 ? args[++i] ?? '' : a.slice(eq + 1));
261
+ }
262
+ else if (a.startsWith('-')) {
263
+ // Short options run together (-qm, -aF file); one that takes a value takes the rest of the word, or the next word.
264
+ for (let j = 1; j < a.length; j++) {
265
+ if ('uS'.includes(a[j]))
266
+ break;
267
+ if (!'mFtcC'.includes(a[j]))
268
+ continue;
269
+ const value = a.slice(j + 1) || (args[++i] ?? '');
270
+ if (a[j] !== 'm')
271
+ values.push(value);
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ return values;
277
+ }
278
+ function git(args, where, protectedBranches) {
279
+ const [sub, ...rest] = args[0] === '--no-pager' ? args.slice(1) : args;
280
+ if (sub === 'status' || sub === 'diff' || sub === 'log' || sub === 'show' || sub === 'add') {
281
+ const excluded = sub === 'add' && rest.includes(EXCLUDE_ENV);
282
+ const named = excluded ? rest.filter((a) => a !== EXCLUDE_ENV) : rest;
283
+ // After `--` every word is a path, a dash in front too.
284
+ const end = named.indexOf('--');
285
+ const paths = end < 0 ? pathsOf(named) : [...pathsOf(named.slice(0, end)), ...named.slice(end + 1)];
286
+ // `HEAD:.env` names a file too, and git's own patterns (`:(top).env`, `*env`) match dot files.
287
+ if (paths.some((a) => /^:[(!^]|[*?[]/.test(a) || isSecretFile(a.slice(a.lastIndexOf(':') + 1))))
288
+ return deny('names a .env file, or a pattern that can match one');
289
+ if (paths.some((a) => outside(a, where)) || rest.some((a) => a.startsWith('--output')))
290
+ return deny('a path outside the checkout');
291
+ if (sub === 'add' && !excluded && (addsAll(named) || paths.some((a) => isFolder(realPath(resolve(where.cwd, a)))))) {
292
+ return deny(`a git add of every change or of a folder stages .env files too: name the files, or add '${EXCLUDE_ENV}' to the command`);
293
+ }
294
+ // The pathspec leaves out the .env files under the folder the command runs in, and -A reaches past it.
295
+ if (excluded && addsAll(named) && realPath(where.cwd) !== where.root)
296
+ return deny('a git add -A or -u from a folder below the top of the checkout: run it from the top');
297
+ return allow();
298
+ }
299
+ if (sub === 'commit') {
300
+ const read = commitReads(rest).find((v) => /[*?[]/.test(v) || isSecretFile(v.slice(v.lastIndexOf(':') + 1)) || isSecretFile(realPath(resolve(where.cwd, v))) || (outside(v, where) && !inScratch(v, where)));
301
+ return read === undefined ? allow() : deny('a commit that reads a file outside the checkout or the scratch folder, or a .env file');
302
+ }
303
+ if (sub === 'checkout' && rest[0] === '-b' && rest.length >= 2 && rest.length <= 3)
304
+ return allow();
305
+ if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
306
+ const branch = rest[2];
307
+ return branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch) ? deny('a push to the default branch') : allow();
308
+ }
309
+ return deny('a git command off the allowlist');
310
+ }
311
+ /** The script names of the package.json in `dir`; none when there is none to read. */
312
+ function scriptsIn(dir) {
313
+ try {
314
+ const scripts = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts;
315
+ return new Set(scripts && typeof scripts === 'object' ? Object.keys(scripts) : []);
316
+ }
317
+ catch {
318
+ return new Set();
319
+ }
320
+ }
321
+ /**
322
+ * package.json and the files the scripts and listed tools load from the checkout by convention (`*.config.js`, `.*rc`): once the agent
323
+ * changes one, what runs is no longer the repository's own code.
324
+ */
325
+ /** The ci-tunnel case corrects Vite's preview.allowedHosts and proves it with the local preview check, which runs in its own copy and environment: after that edit only the shell stops. */
326
+ const VITE_CONFIG = /(?:^|\/)vite\.config\.[cm]?[jt]s$/i;
327
+ const LOADED = /(?:^|\/)(?:package\.json|[\w.-]+\.config\.[cm]?[jt]s|\.[\w-]+rc(?:\.\w+)*|tsconfig[^/]*\.json|knexfile\.[^/]+|bunfig\.toml|prisma\/seed\.[^/]+|node_modules\/.+)$/i;
328
+ /** Why scripts and tools stop running: the agent changed a package.json (true when it does not say which), or a file named here that they load. */
329
+ function changedReason(changed) {
330
+ return changed === true || basename(changed) === 'package.json' ? 'runs package.json scripts the agent changed' : `runs scripts and tools that load ${changed}, which the agent changed`;
331
+ }
332
+ /** A script of the package.json where the command runs: the repository's own build steps, but none that publishes or deploys. */
333
+ function script(name, rest, where, scriptsChanged) {
334
+ if (scriptsChanged)
335
+ return deny(changedReason(scriptsChanged));
336
+ if (!scriptsIn(where.cwd).has(name))
337
+ return deny(`"${name}" is not a script in the package.json there`);
338
+ if (/publish|release|deploy/i.test(name))
339
+ return deny('a script that publishes or deploys');
340
+ return inside(rest, where) ? allow() : deny('a path outside the checkout');
341
+ }
342
+ /** A tool from the list, through npx, pnpm exec, yarn or bunx. */
343
+ function tool(args, where, scriptsChanged = false) {
344
+ let i = 0;
345
+ while (['-y', '--yes', '--no', '--no-install', '-q', '--quiet', '--bun'].includes(args[i] ?? ''))
346
+ i++;
347
+ // A version or a tag may follow the name; an alias such as next@npm:other is another package, so it stays on.
348
+ const name = (args[i] ?? '').replace(/@[\w.^~<>=*+-]+$/, '');
349
+ const rest = args.slice(i + 1);
350
+ if (!TOOLS.has(name))
351
+ return deny(`${name || 'that'} is not on the tool list`);
352
+ if (name === 'playwright' && (rest[0] === 'install-deps' || rest.includes('--with-deps')))
353
+ return deny('installs system packages');
354
+ if (destroysData(name, rest))
355
+ return never('throws away database data');
356
+ if (scriptsChanged)
357
+ return deny(changedReason(scriptsChanged));
358
+ return inside(rest, where) ? allow() : deny('a path outside the checkout');
359
+ }
360
+ /** A package manager's install, script or version; with `tools`, also a tool from the list through pnpm exec or yarn. */
361
+ function packageManager(pm, args, where, scriptsChanged, tools = true) {
362
+ const [sub = '', ...rest] = args;
363
+ if ((sub === '--version' || sub === '-v') && !rest.length)
364
+ return allow();
365
+ if (args.some((a) => a === '-g' || a === '--global' || a === '--location=global'))
366
+ return deny('a global install');
367
+ if (pm === 'pnpm' && sub === 'exec')
368
+ return tools ? tool(rest, where, scriptsChanged) : deny('a tool outside the preview list');
369
+ // yarn on its own installs, as it does with only flags after it.
370
+ const install = ['install', 'i', 'ci'].includes(sub) ? rest : pm === 'yarn' && (!sub || sub.startsWith('-')) ? args : null;
371
+ if (install) {
372
+ if (scriptsChanged)
373
+ return deny(changedReason(scriptsChanged));
374
+ return install.every((a) => a.startsWith('-')) && inside(install, where) ? allow() : deny(`a ${pm} command off the allowlist`);
375
+ }
376
+ if (pm === 'yarn' && !YARN_OWN.has(sub)) {
377
+ // `yarn build` runs the script, `yarn prisma generate` the tool, and `yarn run` either.
378
+ const [name = '', ...extra] = sub === 'run' ? rest : args;
379
+ if (scriptsIn(where.cwd).has(name))
380
+ return script(name, extra, where, scriptsChanged);
381
+ return tools ? tool([name, ...extra], where, scriptsChanged) : deny('a tool outside the preview list');
382
+ }
383
+ if (sub === 'run' && rest[0] && !rest[0].startsWith('-'))
384
+ return script(rest[0], rest.slice(1), where, scriptsChanged);
385
+ return deny(`a ${pm} command off the allowlist`);
386
+ }
387
+ function reader(cmd, args, where) {
388
+ let paths = pathsOf(args);
389
+ if (cmd === 'find') {
390
+ if (args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a)))
391
+ return deny('find that runs or deletes');
392
+ const firstExpression = args.findIndex((a) => a.startsWith('-') || a === '!' || a === '(');
393
+ paths = firstExpression < 0 ? [...args] : args.slice(0, firstExpression);
394
+ }
395
+ if (cmd === 'grep') {
396
+ const recursive = args.some((a) => /^-[^-]*[rR]/.test(a) || a === '--recursive' || a === '--dereference-recursive' || a === '--directories=recurse');
397
+ // A recursive grep reads files nobody named, .env ones too, unless it leaves them out.
398
+ if (recursive && !args.includes('--exclude=.env*'))
399
+ return deny('a recursive grep reads .env files unless it has --exclude=.env*');
400
+ if (!args.some((a) => a === '-e' || a === '-f' || a.startsWith('--regexp') || a.startsWith('--file')))
401
+ paths = paths.slice(1);
402
+ }
403
+ // The scratch folder holds what this run wrote there, such as a build's log, so it may be read back.
404
+ if (paths.some((p) => outside(p, where) && !inScratch(p, where)))
405
+ return deny('reads outside the checkout');
406
+ // The shell expands `.e*` to .env too.
407
+ if (cmd !== 'ls' && paths.some((p) => isSecretFile(p) || /^\.[^/]*[*?[]/.test(basename(p))))
408
+ return deny('reads a .env file, or a pattern that can match one');
409
+ return allow();
410
+ }
411
+ /** A host's CLI package, by host: the one global install that runs, and only once the person said yes to setting that host up. */
412
+ const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
413
+ /** Whether this is a global install of a host's CLI and nothing else, the person having said yes to setting that host up. */
414
+ function installsHostCli(pm, args, hosts) {
415
+ const rest = args.filter((a) => !['-g', '--global', '--location=global'].includes(a));
416
+ const global = pm === 'yarn' ? rest[0] === 'global' : rest.length < args.length;
417
+ const [verb = '', name = '', ...more] = pm === 'yarn' ? rest.slice(1) : rest;
418
+ const verbs = { npm: ['install', 'i', 'add'], pnpm: ['add'], yarn: ['add'], bun: ['add', 'install', 'i'] };
419
+ const cli = name.replace(/@(latest|\d[\w.-]*)$/, '');
420
+ return global && (verbs[pm] ?? []).includes(verb) && !more.length && Object.entries(HOST_CLIS).some(([host, pkg]) => pkg === cli && hosts.has(host));
421
+ }
422
+ /** The Netlify CLI's options that take a value, of those the rules below allow, so the value is not read as a command. */
423
+ const NETLIFY_VALUES = new Set(['--name', '--context', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
424
+ /** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, the one deploy context, a team's slug. */
425
+ const NETLIFY_VALID = {
426
+ '--name': /^[A-Za-z0-9-]{1,63}$/,
427
+ '--id': /^[0-9a-f-]{8,64}$/i,
428
+ '--git-remote-url': /^(?:https:\/\/github\.com\/)?[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?(?:\.git)?$/,
429
+ '--git-remote-name': /^[A-Za-z0-9._-]{1,50}$/,
430
+ '--context': /^deploy-preview$/,
431
+ '--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
432
+ };
433
+ /** Whether the folder is linked to a Netlify site: `.netlify/state.json` names it. */
434
+ function linkedToSite(dir) {
435
+ try {
436
+ return typeof JSON.parse(readFileSync(join(dir, '.netlify', 'state.json'), 'utf8')).siteId === 'string';
437
+ }
438
+ catch {
439
+ return false;
440
+ }
441
+ }
442
+ /**
443
+ * The Netlify CLI: `--version` and `status` at any time; once the person signed in to Netlify for the setup, the
444
+ * project's link and creation, in the person's own current team or the one team they chose (`team`), and a built draft
445
+ * deploy for a deploy preview of a linked folder. The sign-in and the repository's connection are the tools' (a
446
+ * `netlify login` outside the sign-in screen, and a `netlify init` that asks its questions in a terminal, never run).
447
+ * Never production or a project's removal, and nothing of its environment variables, its API (which reaches them) or
448
+ * a token on the command line.
449
+ */
450
+ function netlify(args, where, agreed, team) {
451
+ const words = [];
452
+ const options = new Map();
453
+ for (let i = 0; i < args.length; i++) {
454
+ const arg = args[i];
455
+ if (!arg.startsWith('-')) {
456
+ words.push(arg);
457
+ continue;
458
+ }
459
+ const at = arg.indexOf('=');
460
+ const name = at > 0 ? arg.slice(0, at) : arg;
461
+ // A value never starts with a dash, so an option can hide no other behind it.
462
+ const value = at > 0 ? arg.slice(at + 1) : NETLIFY_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
463
+ options.set(name, value);
464
+ }
465
+ const [sub = '', ...rest] = words;
466
+ if (options.has('--auth'))
467
+ return deny('puts a Netlify token on the command line');
468
+ if (options.has('--prod') || options.has('-p') || options.has('--prod-if-unlocked'))
469
+ return never('deploys to production');
470
+ if (sub === 'sites:delete' || sub === 'sites:remove')
471
+ return never('removes a Netlify project');
472
+ if (/^env:(set|unset|import|clone|migrate)$/.test(sub))
473
+ return never("changes a Netlify project's environment variables");
474
+ if ((!sub && options.size === 1 && (options.has('--version') || options.has('-v'))) || (sub === 'status' && !rest.length && [...options.keys()].every((n) => n === '--json')))
475
+ return allow();
476
+ if (sub === 'login')
477
+ return deny('the Netlify sign-in is impeccable_ask\'s, with the id netlify-setup: it opens the browser from the sign-in screen');
478
+ if (sub === 'init')
479
+ return deny('netlify init asks its questions in a terminal this run does not have: impeccable_netlify_connect connects the repository');
480
+ if (!agreed)
481
+ return deny('sets Netlify up, and the person has not signed in to Netlify for that');
482
+ /** Only these options, each with a value of its kind. */
483
+ const only = (...names) => [...options].every(([n, v]) => names.includes(n) && (!NETLIFY_VALUES.has(n) || (typeof v === 'string' && NETLIFY_VALID[n].test(v))));
484
+ if (options.has('--account-slug') && options.get('--account-slug') !== team)
485
+ return deny('names a Netlify team the person did not choose');
486
+ switch (sub) {
487
+ case 'link':
488
+ return !rest.length && options.size >= 1 && only('--name', '--id', '--git-remote-url', '--git-remote-name') ? allow() : deny('a netlify link off the allowlist');
489
+ case 'sites:create':
490
+ return !rest.length && only('--name', '--json', '--account-slug') ? allow() : deny('a netlify sites:create off the allowlist');
491
+ case 'deploy':
492
+ if (rest.length || !options.has('--build') || !options.has('--context') || !only('--build', '--context', '--json'))
493
+ return deny('a netlify deploy other than a built draft for the deploy-preview context');
494
+ // Where no site is linked the CLI creates one with a random name, which is not what the person said yes to.
495
+ return linkedToSite(where.cwd) ? allow() : deny('a netlify deploy in a folder linked to no site');
496
+ default:
497
+ return deny(sub.startsWith('env') ? "Netlify's environment variables are the person's" : `netlify ${sub || 'without a command'} is not on the allowlist`);
498
+ }
499
+ }
500
+ /** The Vercel CLI's options that take a value, of those the rules below allow, so the value is not read as a command. */
501
+ const VERCEL_VALUES = new Set(['--cwd', '--target', '--scope', '-S', '--team']);
502
+ /** The options that name a team: the CLI never picks one without a terminal, so the run names the one the person chose. */
503
+ const VERCEL_TEAM = ['--scope', '-S', '--team'];
504
+ /**
505
+ * The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
506
+ * project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
507
+ * the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
508
+ * `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
509
+ * environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
510
+ * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
511
+ * line.
512
+ */
513
+ function vercel(args, where, agreed, team) {
514
+ const words = [];
515
+ const options = new Map();
516
+ for (let i = 0; i < args.length; i++) {
517
+ const arg = args[i];
518
+ if (!arg.startsWith('-')) {
519
+ words.push(arg);
520
+ continue;
521
+ }
522
+ const at = arg.indexOf('=');
523
+ const name = at > 0 ? arg.slice(0, at) : arg;
524
+ // A value never starts with a dash, so an option can hide no other behind it.
525
+ const value = at > 0 ? arg.slice(at + 1) : VERCEL_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
526
+ options.set(name, value);
527
+ }
528
+ const [sub = '', ...rest] = words;
529
+ if ([...options.keys()].some((name) => name === '--token' || /^-t/.test(name)))
530
+ return deny('puts a Vercel token on the command line');
531
+ if (options.has('--prod') || (options.has('--target') && options.get('--target') !== 'preview'))
532
+ return never('deploys to production');
533
+ if (sub === 'project' && ['rm', 'remove'].includes(rest[0] ?? ''))
534
+ return never('removes a Vercel project');
535
+ if ((!sub && options.size === 1 && (options.has('--version') || options.has('-v'))) || (sub === 'whoami' && !rest.length && !options.size))
536
+ return allow();
537
+ if (sub === 'login')
538
+ return deny('the Vercel sign-in is impeccable_ask\'s, with the id vercel-setup: it opens the browser from the sign-in screen');
539
+ if (!agreed)
540
+ return deny('sets Vercel up, and the person has not signed in to Vercel for that');
541
+ const cwd = options.get('--cwd');
542
+ if (cwd !== undefined && (typeof cwd !== 'string' || outside(cwd, where)))
543
+ return deny('points the Vercel CLI outside the checkout');
544
+ if (VERCEL_TEAM.some((name) => options.has(name) && options.get(name) !== team))
545
+ return deny('names a Vercel team the sign-in did not settle on');
546
+ /** Only these options, beside the folder and the prompts' absence. */
547
+ const only = (...names) => [...options.keys()].every((n) => [...names, '--cwd', '--non-interactive'].includes(n));
548
+ switch (sub) {
549
+ case 'link':
550
+ return !rest.length && only('--yes', '-y', ...VERCEL_TEAM) ? allow() : deny('a vercel link off the allowlist');
551
+ case 'project':
552
+ return ['add', 'ls', 'list'].includes(rest[0] ?? '') && rest.length <= 2 && only(...VERCEL_TEAM) ? allow() : deny('a vercel project command off the allowlist');
553
+ case 'git':
554
+ return rest.join(' ') === 'connect' && only(...VERCEL_TEAM) ? allow() : deny('a vercel git command other than connect');
555
+ case 'deploy':
556
+ return rest.length <= 1 && inside(rest, where) && only('--yes', '-y', '--target', ...VERCEL_TEAM) ? allow() : deny('a vercel deploy off the preview allowlist');
557
+ case 'env':
558
+ return deny("Vercel's environment variables are the person's");
559
+ default:
560
+ return deny(`vercel ${sub || 'without a command'} is not on the allowlist`);
561
+ }
562
+ }
563
+ function simpleCommand(seg, where, opts) {
564
+ const env = seg.words.slice(0, envCount(seg.words));
565
+ const words = seg.words.slice(env.length);
566
+ for (const r of seg.redirects) {
567
+ if (r.op.includes('>') && r.target !== '/dev/null' && outside(r.target, where) && !intoScratch(r.target, where))
568
+ return never('writes outside the checkout');
569
+ }
570
+ if (words.includes('sudo'))
571
+ return never('sudo');
572
+ if (rmRecursiveForce(words))
573
+ return never('rm -rf');
574
+ if (words[0] === 'git' && words.includes('push') && forcePush(words.slice(words.indexOf('push') + 1)))
575
+ return never('a force push');
576
+ if (words[0] === 'git' && words.includes('reset') && words.includes('--hard'))
577
+ return never('git reset --hard');
578
+ const [cmd = '', ...args] = words;
579
+ const positional = pathsOf(args);
580
+ if (WRITES_EVERY_ARG.has(cmd) && positional.some((a) => outside(a, where)))
581
+ return never('writes outside the checkout');
582
+ if (WRITES_LAST_ARG.has(cmd) && positional.length && outside(positional[positional.length - 1], where))
583
+ return never('writes outside the checkout');
584
+ if (cmd === 'dd' && args.some((a) => a.startsWith('of=') && outside(a.slice(3), where)))
585
+ return never('writes outside the checkout');
586
+ // Output may go into the scratch folder, and input may come out of it; /dev/null takes and gives nothing.
587
+ const scratch = (r) => (r.op.includes('>') ? intoScratch(r.target, where) : inScratch(r.target, where));
588
+ const redirects = seg.redirects.filter((r) => r.target !== '/dev/null');
589
+ if (redirects.some((r) => r.op.includes('>') && !scratch(r)))
590
+ return deny('writes a file through the shell');
591
+ if (redirects.some((r) => outside(r.target, where) && !scratch(r)))
592
+ return deny('reads outside the checkout');
593
+ // Input from a file is a read like a named path, so a .env file stays closed that way too, in the checkout or the scratch folder.
594
+ if (redirects.some((r) => r.op.includes('<') && isSecretFile(r.target)))
595
+ return deny('reads a .env file');
596
+ if (!cmd)
597
+ return deny(env.length ? 'sets environment variables and runs nothing' : 'a redirection on its own');
598
+ if (env.length && !TAKES_SETTINGS.has(cmd))
599
+ return deny(`settings in front of ${cmd}, where only a package manager or a tool runner takes them`);
600
+ for (const assignment of env) {
601
+ const problem = envProblem(assignment, where);
602
+ if (problem)
603
+ return deny(problem);
604
+ }
605
+ switch (cmd) {
606
+ case 'cd':
607
+ if (args.length !== 1 || outside(args[0], where))
608
+ return deny('leaves the checkout');
609
+ // A cd that may be skipped (after && or ||), runs in a pipe, or goes nowhere leaves what follows where it was.
610
+ if (!seg.certain)
611
+ return deny('a cd that runs only sometimes: put it first, or after ;');
612
+ return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
613
+ case 'git':
614
+ return git(args, where, opts.protectedBranches);
615
+ case 'gh':
616
+ if (args[0] === 'pr' && (args[1] === 'create' || args[1] === 'view'))
617
+ return allow();
618
+ // The test drive opens as a draft where its preview needs the pull request first, and is marked ready once that preview is proven: the test drive's branch by name, nothing else.
619
+ if (args[0] === 'pr' && args[1] === 'ready')
620
+ return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
621
+ return deny('a gh command off the allowlist');
622
+ case 'npx':
623
+ case 'bunx':
624
+ return tool(args, where, opts.scriptsChanged);
625
+ case 'node':
626
+ case 'python':
627
+ case 'python3':
628
+ case 'ruby':
629
+ return args.length === 1 && ['--version', '-V', '-v'].includes(args[0]) ? allow() : deny('only runtime version checks run here');
630
+ case 'ls':
631
+ case 'cat':
632
+ case 'head':
633
+ case 'grep':
634
+ case 'find':
635
+ return reader(cmd, args, where);
636
+ case 'netlify':
637
+ return netlify(args, where, opts.hosts.has('netlify'), opts.teams.get('netlify'));
638
+ case 'vercel':
639
+ return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'));
640
+ default:
641
+ if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
642
+ return allow('installs the CLI of the host the person said yes to');
643
+ return PACKAGE_MANAGERS.has(cmd) ? packageManager(cmd, args, where, opts.scriptsChanged) : deny(`${cmd} is not on the allowlist`);
644
+ }
645
+ }
646
+ /** The fence's answer for one shell command run from the checkout's top folder. */
647
+ export function classifyCommand(command, root, options = {}) {
648
+ const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map() };
649
+ const segments = parseCommand(command);
650
+ if (!segments)
651
+ return deny('uses shell syntax the fence does not read');
652
+ if (!segments.length)
653
+ return deny('an empty command');
654
+ const where = { root: realPath(root), cwd: root, scratch: options.scratch && realPath(options.scratch) };
655
+ let result = allow();
656
+ for (const seg of segments) {
657
+ const decision = simpleCommand(seg, where, opts);
658
+ if (decision.verdict === 'never')
659
+ return decision;
660
+ if (decision.verdict === 'deny' && result.verdict === 'allow')
661
+ result = decision;
662
+ // Only a cd that surely runs, in this shell, into a folder that is there moves what follows, so each later path and script is judged where it runs.
663
+ if (seg.words[0] === 'cd' && decision.verdict === 'allow')
664
+ where.cwd = resolve(where.cwd, seg.words[1]);
665
+ }
666
+ return result;
667
+ }
668
+ /**
669
+ * Commands for the disposable local preview verifier only. This is not a new
670
+ * shell allowlist for the person's checkout: the verifier supplies an isolated
671
+ * copy, environment, database and process lifetime. No arbitrary evaluators,
672
+ * deployment commands, redirects or background processes are accepted here.
673
+ */
674
+ export function classifyPreviewCommand(command, root, options = {}) {
675
+ const segments = parseCommand(command);
676
+ if (!segments?.length)
677
+ return deny('unsupported preview command syntax');
678
+ const where = { root: realPath(root), cwd: root };
679
+ const appTool = (name, args) => {
680
+ if (args.some((a) => a === '--remote' || a.startsWith('--remote=') || isSecretFile(a)))
681
+ return false;
682
+ if (!inside(args, where))
683
+ return false;
684
+ switch (name) {
685
+ case 'prisma':
686
+ return args[0] === 'generate' || (args[0] === 'db' && ['push', 'seed'].includes(args[1] ?? '')) || (args[0] === 'migrate' && args[1] === 'deploy');
687
+ case 'nuxt': return ['prepare', 'build', 'dev', 'preview'].includes(args[0] ?? '');
688
+ case 'next': return ['build', 'dev', 'start'].includes(args[0] ?? '');
689
+ case 'vite': return !args.length || ['build', 'dev', 'preview', '--host'].includes(args[0] ?? '');
690
+ case 'astro': return ['build', 'dev', 'preview'].includes(args[0] ?? '');
691
+ case 'wrangler':
692
+ return args.includes('--local') && !args.some((a) => a.startsWith('--local=')) && (args[0] === 'dev' || (args[0] === 'd1' &&
693
+ ((args[1] === 'migrations' && args[2] === 'apply') || (args[1] === 'execute' && args.includes('--file')))));
694
+ case 'rails':
695
+ case 'bin/rails': return ['server', 'db:prepare', 'db:migrate', 'db:seed'].includes(args[0] ?? '');
696
+ case 'rake': return ['db:prepare', 'db:migrate', 'db:seed'].includes(args[0] ?? '');
697
+ case 'uvicorn': return /^[A-Za-z_][\w.]*:[A-Za-z_]\w*$/.test(args[0] ?? '');
698
+ default: return false;
699
+ }
700
+ };
701
+ for (const segment of segments) {
702
+ if (segment.redirects.length)
703
+ return deny('preview output is managed by the verifier');
704
+ const [cmd = '', ...args] = segment.words;
705
+ if (cmd === 'cd') {
706
+ if (!segment.certain || args.length !== 1 || outside(args[0], where))
707
+ return deny('preview directory leaves its copy, or may not be entered');
708
+ where.cwd = resolve(where.cwd, args[0]);
709
+ continue;
710
+ }
711
+ if (args.some(isSecretFile) || !inside(args, where))
712
+ return deny('preview arguments leave the copy or read live environment files');
713
+ let permitted = false;
714
+ if (PACKAGE_MANAGERS.has(cmd)) {
715
+ if (options.scriptsChanged)
716
+ return deny('runs package.json scripts the agent changed');
717
+ // Tools go through appTool's shorter list here, never through the checkout's.
718
+ permitted = packageManager(cmd, args, where, false, false).verdict === 'allow' ||
719
+ (args[0] === 'exec' && appTool(args[1] ?? '', args.slice(2)));
720
+ }
721
+ else if (cmd === 'npx') {
722
+ if (options.scriptsChanged)
723
+ return deny('runs package.json scripts the agent changed');
724
+ const at = args[0] === '--no-install' ? 1 : 0;
725
+ permitted = appTool(args[at] ?? '', args.slice(at + 1));
726
+ }
727
+ else if (['python', 'python3', '.venv/bin/python', '.venv/bin/python3'].includes(cmd)) {
728
+ if (args[0] === '-m') {
729
+ permitted = args[1] === 'venv' && args.length === 3 && args[2] === '.venv';
730
+ if (args[1] === 'pip')
731
+ permitted = args.length === 5 && args[2] === 'install' && args[3] === '-r' && Boolean(args[4]);
732
+ if (args[1] === 'uvicorn')
733
+ permitted = appTool('uvicorn', args.slice(2));
734
+ }
735
+ else {
736
+ permitted = Boolean(args[0]?.endsWith('.py')) && !args[0].startsWith('-');
737
+ if (basename(args[0] ?? '') === 'manage.py') {
738
+ permitted = ['migrate', 'collectstatic', 'runserver', 'check', 'loaddata', 'seed', 'seed_demo'].includes(args[1] ?? '') &&
739
+ !args.some((a) => /^(?:--database|--settings|--pythonpath)(?:=|$)/.test(a));
740
+ }
741
+ }
742
+ }
743
+ else if (cmd === 'bundle') {
744
+ permitted = args[0] === 'install' && args.slice(1).every((a) => a === '--local' || a === '--quiet');
745
+ if (args[0] === 'exec')
746
+ permitted = appTool(args[1] ?? '', args.slice(2));
747
+ }
748
+ else if (cmd === 'node') {
749
+ permitted = Boolean(args[0]) && !args[0].startsWith('-') && args[0] !== '-';
750
+ }
751
+ else {
752
+ permitted = appTool(cmd, args);
753
+ }
754
+ if (!permitted)
755
+ return deny('command is outside the local preview allowlist');
756
+ }
757
+ return allow('inside the disposable preview verifier');
758
+ }
759
+ /** The fence's answer for pi's file tools: writes stay in the checkout and out of .git; reads stay in it or in the run's scratch folder, and off .env files. */
760
+ export function classifyFileTool(tool, path, root, scratch) {
761
+ const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
762
+ const p = typeof path === 'string' && path ? path : '.';
763
+ if (tool === 'write' || tool === 'edit') {
764
+ if (outside(p, where))
765
+ return never('writes outside the checkout');
766
+ return within(realPath(resolve(root, p)), join(where.root, '.git')) ? never('writes into .git, where hooks run') : allow();
767
+ }
768
+ if (outside(p, where) && !inScratch(p, where))
769
+ return deny('reads outside the checkout');
770
+ if ((tool === 'read' || tool === 'grep') && isSecretFile(p))
771
+ return deny('reads a .env file');
772
+ return allow();
773
+ }
774
+ const previewRetries = new WeakMap();
775
+ export function previewRetryState(log) {
776
+ let state = previewRetries.get(log);
777
+ if (!state) {
778
+ state = { used: 0, pushReservations: 0, creationReserved: false, drivePushes: 0, driveCreated: false, requireHistory: false, historyKnown: false, observed: new Map() };
779
+ previewRetries.set(log, state);
780
+ }
781
+ return state;
782
+ }
783
+ const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
784
+ const driveBranch = (branch) => /^impeccable\/test-drive(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
785
+ /** `gh pr ready <the test drive's branch> [--repo <owner>/<name>]`, and nothing else (never `--undo`, a number or another branch): the branch and the repository named, or null. */
786
+ function ghReady(args) {
787
+ const [branch, ...rest] = args;
788
+ if (!branch || !driveBranch(branch))
789
+ return null;
790
+ if (!rest.length)
791
+ return { branch };
792
+ const inline = rest[0]?.startsWith('--repo=') ? rest[0].slice('--repo='.length) : undefined;
793
+ const repository = inline ?? (rest[0] === '--repo' || rest[0] === '-R' ? rest[1] : undefined);
794
+ return rest.length === (inline !== undefined ? 1 : 2) && typeof repository === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/.test(repository) ? { branch, repository } : null;
795
+ }
796
+ const pushBranch = (words, literal = setupBranch) => words.length === 5 && words[0] === 'git' && words[1] === 'push'
797
+ && ['-u', '--set-upstream'].includes(words[2]) && words[3] === 'origin' && literal(words[4]) ? words[4] : undefined;
798
+ function createArguments(args) {
799
+ const values = new Map();
800
+ const names = {
801
+ '-H': 'head', '--head': 'head', '-R': 'repo', '--repo': 'repo', '-B': 'base', '--base': 'base',
802
+ '-t': 'title', '--title': 'title', '-b': 'body', '--body': 'body', '-F': 'body-file', '--body-file': 'body-file',
803
+ '-a': 'assignee', '--assignee': 'assignee', '-r': 'reviewer', '--reviewer': 'reviewer',
804
+ '-l': 'label', '--label': 'label', '-m': 'milestone', '--milestone': 'milestone', '-p': 'project', '--project': 'project',
805
+ };
806
+ const booleans = new Set(['-d', '--draft', '--no-maintainer-edit', '-f', '--fill', '--fill-first', '--fill-verbose']);
807
+ const repeatable = new Set(['assignee', 'reviewer', 'label', 'project']);
808
+ let filled = false;
809
+ for (let i = 0; i < args.length; i++) {
810
+ const arg = args[i];
811
+ if (booleans.has(arg)) {
812
+ filled ||= ['-f', '--fill', '--fill-first', '--fill-verbose'].includes(arg);
813
+ continue;
814
+ }
815
+ const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
816
+ const name = names[at < 0 ? arg : arg.slice(0, at)];
817
+ if (!name)
818
+ return;
819
+ const value = at < 0 ? args[++i] : arg.slice(at + 1);
820
+ if (value === undefined || (at < 0 && value.startsWith('-')) || (values.has(name) && !repeatable.has(name)))
821
+ return;
822
+ values.set(name, value);
823
+ }
824
+ const head = values.get('head'), bodyFile = values.get('body-file');
825
+ if (head !== undefined && !setupBranch(head) && !driveBranch(head))
826
+ return;
827
+ if (!filled && (!values.get('title')?.trim() || (!values.has('body') && !values.has('body-file'))))
828
+ return;
829
+ if (values.has('body') && values.has('body-file'))
830
+ return;
831
+ if (values.has('body-file') && (!bodyFile || bodyFile === '-'))
832
+ return;
833
+ return { head, repository: values.get('repo'), bodyFile };
834
+ }
835
+ /** Called synchronously only after the ordinary fence allows the entire command. */
836
+ export function reservePreviewCommand(state, command, root, scratch) {
837
+ const segments = parseCommand(command);
838
+ if (!segments)
839
+ return deny('unsupported command syntax');
840
+ const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
841
+ let used = state.used, pushes = state.pushReservations, create = state.createBranch, reserved = state.creationReserved;
842
+ let drive = state.driveBranch, drivePushes = state.drivePushes, driveCreated = state.driveCreated;
843
+ for (const { words } of segments) {
844
+ if (words[0] === 'cd') {
845
+ where.cwd = resolve(where.cwd, words[1]);
846
+ continue;
847
+ }
848
+ // The test drive is the run's other pull request: one branch, one pull request, a few pushes, none of the setup pull request's attempts.
849
+ const drivePush = pushBranch(words, driveBranch);
850
+ if (drivePush) {
851
+ if (drive && drivePush !== drive)
852
+ return deny('keep the test drive on one branch');
853
+ if (++drivePushes > 3)
854
+ return deny('the test drive has been pushed three times already');
855
+ drive = drivePush;
856
+ continue;
857
+ }
858
+ if (words[0] === 'git' && words[1] === 'push') {
859
+ const branch = pushBranch(words), locked = state.pr?.branch ?? create;
860
+ if (!branch)
861
+ return deny('push only a literal setup branch');
862
+ if (locked && branch !== locked)
863
+ return deny('keep corrections on the setup pull request branch');
864
+ if (reserved && !locked)
865
+ return deny('record the created setup pull request before another push');
866
+ if (state.recordedPr && !state.pr)
867
+ return deny('verify the recorded setup pull request before another push');
868
+ if (state.pr && state.requireHistory && !state.historyKnown)
869
+ return deny('read the setup preview attempts before another push');
870
+ if (++used > 3)
871
+ return deny('three preview attempts have already been reserved or observed');
872
+ pushes++;
873
+ }
874
+ if (words[0] === 'gh' && words[1] === 'pr' && words[2] === 'create') {
875
+ const parsed = createArguments(words.slice(3));
876
+ if (!parsed)
877
+ return deny('create each pull request with a title and a body, or --fill');
878
+ const body = parsed.bodyFile;
879
+ if (body && (body.startsWith('~') || /[$*?[]/.test(body) || isSecretFile(body) || isSecretFile(realPath(resolve(where.cwd, body)))
880
+ || (outside(body, where) && !inScratch(body, where))))
881
+ return deny('use a non-secret body file inside the checkout or run scratch');
882
+ if (state.requireHistory && (!parsed.head || !parsed.repository))
883
+ return deny('name the repository and literal head explicitly for self-hosted previews');
884
+ if (!state.repository || (parsed.repository !== undefined && parsed.repository.toLowerCase() !== state.repository.toLowerCase())
885
+ || (!parsed.repository && realPath(where.cwd) !== where.root))
886
+ return deny('name this repository explicitly when creating a pull request');
887
+ if (parsed.head && driveBranch(parsed.head)) {
888
+ if (driveCreated)
889
+ return deny('the test drive is already open');
890
+ if (drive && parsed.head !== drive)
891
+ return deny('open the test drive from the branch that was pushed');
892
+ drive = parsed.head;
893
+ driveCreated = true;
894
+ continue;
895
+ }
896
+ if (state.recordedPr || state.pr || reserved)
897
+ return deny('the setup pull request is already created or reserved');
898
+ // Hosted PR creation is not itself a preview attempt. Self-hosted flows
899
+ // require --head above, so gh cannot spend an uncounted implicit push there.
900
+ create = parsed.head;
901
+ reserved = true;
902
+ }
903
+ // Marking a draft ready starts its review: only the test drive this run opened, in this repository (named, or the checkout's own), never another repository's draft.
904
+ if (words[0] === 'gh' && words[1] === 'pr' && words[2] === 'ready') {
905
+ const parsed = ghReady(words.slice(3));
906
+ if (!parsed)
907
+ return deny('mark ready only the test drive, by its branch name');
908
+ if (!state.repository || (parsed.repository !== undefined && parsed.repository.toLowerCase() !== state.repository.toLowerCase())
909
+ || (!parsed.repository && realPath(where.cwd) !== where.root))
910
+ return deny('name this repository explicitly when marking the test drive ready');
911
+ if (!driveCreated || parsed.branch !== drive)
912
+ return deny('mark ready the test drive this run opened');
913
+ }
914
+ }
915
+ // Every segment passes before any reservation becomes visible to the next tool call.
916
+ if (pushes !== state.pushReservations && state.pr)
917
+ state.historyKnown = false;
918
+ state.used = used;
919
+ state.pushReservations = pushes;
920
+ state.createBranch = create;
921
+ state.creationReserved = reserved;
922
+ state.driveBranch = drive;
923
+ state.drivePushes = drivePushes;
924
+ state.driveCreated = driveCreated;
925
+ return allow();
926
+ }
927
+ function canonicalPreviewPr(state, pr) {
928
+ if (!state.repository || !Number.isSafeInteger(pr.number) || pr.number < 1 || !URL.canParse(pr.url))
929
+ return;
930
+ const url = new URL(pr.url), expected = `/${state.repository}/pull/${pr.number}`;
931
+ if (url.origin !== 'https://github.com' || url.username || url.password || url.search || url.hash
932
+ || url.pathname.replace(/\/$/, '').toLowerCase() !== expected.toLowerCase())
933
+ return;
934
+ return `https://github.com${expected}`;
935
+ }
936
+ /** The caller has already verified this PR is open and its head belongs to this repository. */
937
+ export function bindPreviewPullRequest(state, pr) {
938
+ const url = canonicalPreviewPr(state, pr);
939
+ if (!url || !setupBranch(pr.branch))
940
+ return deny('record a setup pull request in this repository');
941
+ if (state.createBranch && state.createBranch !== pr.branch)
942
+ return deny('record the reserved setup branch');
943
+ if (state.recordedPr && (state.recordedPr.number !== pr.number || canonicalPreviewPr(state, state.recordedPr) !== url))
944
+ return deny('the setup pull request cannot change');
945
+ if (state.pr && (state.pr.number !== pr.number || state.pr.url !== url || state.pr.branch !== pr.branch))
946
+ return deny('the setup pull request cannot change');
947
+ state.pr = { ...pr, url };
948
+ state.recordedPr = { number: pr.number, url };
949
+ state.createBranch = pr.branch;
950
+ state.creationReserved = true;
951
+ return allow();
952
+ }
953
+ /** Only the fixed GitHub observer supplies validated identities, never model arguments. */
954
+ export function observePreviewAttempts(state, runs, historyComplete) {
955
+ if (runs.length > 100 || runs.some(({ runId, attempt }) => !Number.isSafeInteger(runId) || runId < 1 || !Number.isSafeInteger(attempt) || attempt < 1)) {
956
+ state.historyKnown = false;
957
+ return;
958
+ }
959
+ if (new Set([...state.observed.keys(), ...runs.map((run) => run.runId)]).size > 20) {
960
+ state.used = Math.max(state.used, 3);
961
+ state.historyKnown = false;
962
+ return;
963
+ }
964
+ let reruns = 0;
965
+ for (const { runId, attempt } of runs) {
966
+ const previous = state.observed.get(runId);
967
+ reruns += Math.max(0, attempt - (previous ?? 1));
968
+ state.observed.set(runId, Math.max(previous ?? 0, attempt));
969
+ }
970
+ state.used = Math.max(state.used + reruns, [...state.observed.values()].reduce((sum, n) => sum + n, 0));
971
+ state.historyKnown = historyComplete;
972
+ }
973
+ /** The hosts the person said yes to setting up in this run, by the run's log. */
974
+ const agreedHosts = new WeakMap();
975
+ function hostsAgreed(log) {
976
+ let hosts = agreedHosts.get(log);
977
+ if (!hosts)
978
+ agreedHosts.set(log, (hosts = new Set()));
979
+ return hosts;
980
+ }
981
+ /** A yes to impeccable_ask's confirm "<host>-setup", or the host's CLI found signed in there, lets that host's CLI run for the rest of the run. */
982
+ export function agreeToHostSetup(log, questionId) {
983
+ const host = /^(.+)-setup$/.exec(questionId)?.[1];
984
+ if (host && Object.hasOwn(HOST_CLIS, host))
985
+ hostsAgreed(log).add(host);
986
+ }
987
+ /** Whether the person opened this host's setup in this run: the tools that change something on the host ask this, as the fence does for the CLI. */
988
+ export function hostSetupAgreed(log, host) {
989
+ return hostsAgreed(log).has(host);
990
+ }
991
+ /** The team the person picked for each host's CLI in this run, by the run's log. */
992
+ const chosenTeams = new WeakMap();
993
+ function teamsChosen(log) {
994
+ let teams = chosenTeams.get(log);
995
+ if (!teams)
996
+ chosenTeams.set(log, (teams = new Map()));
997
+ return teams;
998
+ }
999
+ /** A pick from impeccable_ask's single choice "<host>-team", after the yes to setting that host up, is the one team that host's CLI may name. */
1000
+ export function chooseHostTeam(log, questionId, team) {
1001
+ const host = /^(.+)-team$/.exec(questionId)?.[1];
1002
+ if (host && Object.hasOwn(HOST_CLIS, host) && hostsAgreed(log).has(host) && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(team))
1003
+ teamsChosen(log).set(host, team);
1004
+ }
1005
+ /** Tool calls a run makes before the fence refuses more: a setup takes far fewer, and a run steered elsewhere stops there. */
1006
+ export const MAX_TOOL_CALLS = 400;
1007
+ /** What still runs past the cap, so the run can say where it got to and end. */
1008
+ const FINISHING = new Set(['impeccable_progress', 'impeccable_report']);
1009
+ /** Names shaped like a credential, which the agent's shell does not inherit: the scripts it runs are the repository's code, run as the person. */
1010
+ const CREDENTIAL = /TOKEN|SECRET|PASSW(?:OR)?D|PASSPHRASE|CREDENTIAL|PRIVATE|(?:^|_)(?:API_?)?KEY(?:_|$)|APIKEY|AUTH(?!OR)|COOKIE|_PWD$|DATABASE_URL$|^(?:AWS|AZURE|ANTHROPIC|OPENAI|OP_SESSION)_/i;
1011
+ /**
1012
+ * The environment for the agent's shell: the person's, without credentials, whether by name or as a URL's password (a database's
1013
+ * connection string), a proxy's too. A folder or a socket is a place, not a secret, so VERCEL_AUTH_CONFIG_DIR and SSH_AUTH_SOCK stay; gh
1014
+ * and git sign in from their own config and keychain.
1015
+ */
1016
+ export function shellEnvironment(env) {
1017
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => /_(?:DIR|HOME|SOCK)$/.test(name)
1018
+ || (!CREDENTIAL.test(name) && !/:\/\/[^/@\s:]*:[^/@\s]+@/.test(value ?? ''))));
1019
+ }
1020
+ export function fenceExtension(root, log, protectedBranches, scratch) {
1021
+ const retry = previewRetryState(log);
1022
+ return (pi) => {
1023
+ /** The first file the agent changed that the scripts and tools load, and the first of those but vite.config, which also stops the local preview check. */
1024
+ let changed;
1025
+ let previewChanged;
1026
+ let calls = 0;
1027
+ pi.on('tool_call', (event) => {
1028
+ if (++calls > MAX_TOOL_CALLS && !FINISHING.has(event.toolName)) {
1029
+ log.write('fence deny (the tool call cap)', event.toolName);
1030
+ return { block: true, reason: `Refused: this run has made ${MAX_TOOL_CALLS} tool calls, its limit. Call impeccable_progress with every task's final status, then impeccable_report with what was done and what was left.` };
1031
+ }
1032
+ const input = event.input;
1033
+ const scriptsChanged = changed ?? false;
1034
+ let decision;
1035
+ let shown;
1036
+ if (event.toolName === 'bash') {
1037
+ shown = String(input.command ?? '');
1038
+ decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log) });
1039
+ if (decision.verdict === 'allow')
1040
+ decision = reservePreviewCommand(retry, shown, root, scratch);
1041
+ }
1042
+ else if (event.toolName === 'impeccable_verify_preview' && previewChanged) {
1043
+ shown = `local preview after an edit of ${previewChanged}`;
1044
+ decision = deny(changedReason(previewChanged));
1045
+ }
1046
+ else if (['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
1047
+ shown = `${event.toolName} ${String(input.path ?? '.')}`;
1048
+ decision = classifyFileTool(event.toolName, input.path, root, scratch);
1049
+ // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
1050
+ const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
1051
+ if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow' && loaded.length) {
1052
+ changed ??= String(input.path);
1053
+ if (!loaded.every((p) => VITE_CONFIG.test(p)))
1054
+ previewChanged ??= String(input.path);
1055
+ }
1056
+ }
1057
+ else {
1058
+ return undefined;
1059
+ }
1060
+ log.write(`fence ${decision.verdict} (${decision.reason})`, shown);
1061
+ if (decision.verdict === 'allow')
1062
+ return undefined;
1063
+ if (decision.verdict === 'never')
1064
+ return { block: true, reason: `Blocked: ${decision.reason}. Impeccable never does this; find another way.` };
1065
+ return {
1066
+ block: true,
1067
+ reason: `Refused: ${decision.reason}. The person is never asked to run a command. Find another way with what runs (a package script, a config change, another tool on the list); if there is none, leave the step undone and put it in the report under "What was skipped, and why", with the exact command for the person.`,
1068
+ };
1069
+ });
1070
+ };
1071
+ }