@impeccable/reviews 0.1.6 → 0.1.8

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 (41) hide show
  1. package/README.md +22 -20
  2. package/dist/agent.js +9 -4
  3. package/dist/cloudflare-access.js +77 -47
  4. package/dist/cloudflare.js +347 -23
  5. package/dist/fence.js +939 -143
  6. package/dist/focus.js +5 -4
  7. package/dist/host-previews.js +123 -0
  8. package/dist/host-questions.js +101 -21
  9. package/dist/hosted.js +72 -21
  10. package/dist/http.js +2 -0
  11. package/dist/log.js +7 -1
  12. package/dist/login.js +45 -13
  13. package/dist/main.js +37 -14
  14. package/dist/netlify-password.js +8 -6
  15. package/dist/netlify.js +58 -16
  16. package/dist/pi-paths.js +69 -0
  17. package/dist/playbook/cases/cloudflare-previews.md +12 -4
  18. package/dist/playbook/cases/first-preview-failure.md +1 -1
  19. package/dist/playbook/cases/live-app-address.md +15 -0
  20. package/dist/playbook/cases/monorepo.md +8 -8
  21. package/dist/playbook/cases/netlify-previews.md +3 -1
  22. package/dist/playbook/cases/netlify-setup.md +7 -6
  23. package/dist/playbook/cases/preview-protection.md +3 -2
  24. package/dist/playbook/cases/pull-requests.md +3 -3
  25. package/dist/playbook/cases/static-site.md +2 -2
  26. package/dist/playbook/cases/vercel-previews.md +3 -1
  27. package/dist/playbook/cases/vercel-setup.md +9 -5
  28. package/dist/playbook.js +3 -3
  29. package/dist/preflight.js +43 -8
  30. package/dist/report.js +1 -0
  31. package/dist/sandbox.js +364 -50
  32. package/dist/scan.js +134 -0
  33. package/dist/sync.js +44 -7
  34. package/dist/tools.js +682 -140
  35. package/dist/ui/bridge.js +18 -4
  36. package/dist/ui/look.js +1 -1
  37. package/dist/ui/screens.js +5 -3
  38. package/dist/ui/sensitive.js +10 -0
  39. package/dist/vercel-bypass.js +21 -12
  40. package/dist/vercel.js +147 -17
  41. package/package.json +1 -1
package/dist/fence.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { readFileSync, realpathSync, statSync } from 'node:fs';
3
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants, existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
+ import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { createFindToolDefinition, createGrepToolDefinition, detectSupportedImageMimeTypeFromFile } from '@earendil-works/pi-coding-agent';
7
+ import { linkedNetlifySite } from './netlify.js';
8
+ import { resolveReadPath, resolveToCwd } from './pi-paths.js';
4
9
  import { gitEnvironment } from './sandbox.js';
5
10
  import { DATA_NOTE, scan, scanRecorder } from './scan.js';
11
+ import { plainText } from './ui/sensitive.js';
6
12
  const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
7
13
  const deny = (reason) => ({ verdict: 'deny', reason });
8
14
  const never = (reason) => ({ verdict: 'never', reason });
@@ -36,7 +42,7 @@ export function parseCommand(command) {
36
42
  if (redirect)
37
43
  return false;
38
44
  if (words.length || redirects.length) {
39
- out.push({ words, redirects, certain: (before === '' || before === ';') && after !== '|' });
45
+ out.push({ words, redirects, certain: (before === '' || before === ';') && after !== '|', before });
40
46
  before = after;
41
47
  }
42
48
  words = [];
@@ -182,13 +188,27 @@ function pathsOf(args) {
182
188
  return args.flatMap((a) => (a.startsWith('--') && a.includes('=') ? [a.slice(a.indexOf('=') + 1)] : a.startsWith('-') ? [] : [a]));
183
189
  }
184
190
  const inside = (args, where) => !pathsOf(args).some((a) => outside(a, where));
191
+ const EXAMPLE_ENDINGS = ['example', 'sample', 'template', 'dist'];
185
192
  /** Files named as examples, whose secrets are placeholders. */
186
- const EXAMPLE = /\.(example|sample|template|dist)$/;
193
+ const EXAMPLE = new RegExp(`\\.(${EXAMPLE_ENDINGS.join('|')})$`);
194
+ /** The .env files' examples, as git's pathspecs, wherever they are in the checkout. */
195
+ const ENV_EXAMPLES = EXAMPLE_ENDINGS.map((end) => `:(top,glob)**/.env*.${end}`);
196
+ /** What a .env file's example is made of, its placeholders: a secret-like name set to a random value, an address's password. A key or a token of a known shape in one is still a live one. */
197
+ const PLACEHOLDER_SHAPES = new Set(['secret-assignment', 'url-password']);
187
198
  /** A .env file, or a pattern that can name one, holds live secrets; the examples next to it do not. */
188
199
  export function isSecretFile(path) {
189
200
  const name = basename(path);
190
201
  return name.startsWith('.env') && !EXAMPLE.test(name);
191
202
  }
203
+ /** A .env file, or a folder named like one, on a resolved path below the checkout or the scratch folder. */
204
+ function secretOnPath(real, where) {
205
+ const from = where.scratch && within(real, where.scratch) ? where.scratch : where.root;
206
+ return relative(from, real).split(/[\\/]/).some(isSecretFile);
207
+ }
208
+ /** A read of a .env file by any name: the name typed, or what it stands for once its symlinks are resolved. */
209
+ function readsSecret(path, where) {
210
+ return isSecretFile(path) || secretOnPath(realPath(resolve(where.cwd, path)), where);
211
+ }
192
212
  function rmRecursiveForce(words) {
193
213
  const at = words.indexOf('rm');
194
214
  if (at < 0)
@@ -249,41 +269,119 @@ function envProblem(assignment, where) {
249
269
  export const EXCLUDE_ENV = ':(exclude,glob)**/.env*';
250
270
  /** `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). */
251
271
  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]))));
252
- /** 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`). */
253
- function commitReads(args) {
254
- const values = [];
272
+ /** git commit's options, read as git does: the values that name a file (a message, a template, a pathspec list) and the commits whose message it takes (which may name a file, `HEAD:.env`), whether -a commits every tracked change, and the paths it names. */
273
+ function commitOptions(args) {
274
+ const files = [], commits = [], paths = [];
275
+ let all = false;
255
276
  for (let i = 0; i < args.length; i++) {
256
277
  const a = args[i];
257
- if (a === '--')
278
+ if (a === '--') {
279
+ paths.push(...args.slice(i + 1));
258
280
  break;
281
+ }
259
282
  if (a.startsWith('--')) {
260
283
  const eq = a.indexOf('=');
261
284
  const name = a.slice(2, eq < 0 ? undefined : eq);
262
- if (name === 'message' && eq < 0)
285
+ const value = () => (eq < 0 ? args[++i] ?? '' : a.slice(eq + 1));
286
+ if (name && ['file', 'template', 'pathspec-from-file'].some((o) => o.startsWith(name)))
287
+ files.push(value());
288
+ else if (name && ['reuse-message', 'reedit-message'].some((o) => o.startsWith(name)))
289
+ commits.push(value());
290
+ else if (name === 'all')
291
+ all = true;
292
+ // The other options with a value (--message, --author, --date, --fixup…): their value is neither a file nor a path.
293
+ else if (name && eq < 0 && ['message', 'author', 'date', 'fixup', 'squash', 'trailer', 'cleanup'].some((o) => o.startsWith(name)))
263
294
  i++;
264
- else if (name && ['file', 'template', 'reuse-message', 'reedit-message', 'pathspec-from-file'].some((o) => o.startsWith(name)))
265
- values.push(eq < 0 ? args[++i] ?? '' : a.slice(eq + 1));
266
295
  }
267
- else if (a.startsWith('-')) {
296
+ else if (a.startsWith('-') && a.length > 1) {
268
297
  // Short options run together (-qm, -aF file); one that takes a value takes the rest of the word, or the next word.
269
298
  for (let j = 1; j < a.length; j++) {
270
- if ('uS'.includes(a[j]))
299
+ const option = a[j];
300
+ if (option === 'a')
301
+ all = true;
302
+ if ('uS'.includes(option))
271
303
  break;
272
- if (!'mFtcC'.includes(a[j]))
304
+ if (!'mFtcC'.includes(option))
273
305
  continue;
274
306
  const value = a.slice(j + 1) || (args[++i] ?? '');
275
- if (a[j] !== 'm')
276
- values.push(value);
307
+ if (option === 'F' || option === 't')
308
+ files.push(value);
309
+ else if (option !== 'm')
310
+ commits.push(value);
277
311
  break;
278
312
  }
279
313
  }
314
+ else {
315
+ paths.push(a);
316
+ }
317
+ }
318
+ return { files, commits, all, paths };
319
+ }
320
+ /** git's words after `git`, from its command on: a leading `--no-pager` dropped; null when another option comes first (`-c`, `-C`, `--git-dir`…), which changes what git runs or where. */
321
+ function gitArgs(args) {
322
+ const from = args[0] === '--no-pager' ? args.slice(1) : args;
323
+ return from[0]?.startsWith('-') ? null : from;
324
+ }
325
+ /** Options that print file contents, or pick commits by them (-S, -G, -L), in git diff, log and show. */
326
+ const PATCH = /^-(?!-)[^\d]*[pucmUWLSG]|^--(?:patch|unified|word-diff|color-words|cc$|dd$|remerge-diff|diff-merges|binary|function-context|find-object|pickaxe)/;
327
+ /** Options that print names and counts in place of the patch git diff and show print by default. */
328
+ const NO_PATCH = new Set(['-s', '--no-patch', '--stat', '--numstat', '--shortstat', '--name-only', '--name-status', '--summary', '--dirstat', '--compact-summary', '--raw', '--quiet']);
329
+ /** Whether git diff, log or show prints file contents: diff and show by default (show's `<rev>:<path>` prints that file, checked by name), log with -p and its kin. */
330
+ function printsPatch(sub, args) {
331
+ const options = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
332
+ if (options.some((a) => PATCH.test(a)))
333
+ return true;
334
+ if (sub === 'log' || options.some((a) => NO_PATCH.has(a.split('=')[0])))
335
+ return false;
336
+ const revisions = options.filter((a) => !a.startsWith('-'));
337
+ return !(sub === 'show' && revisions.length && revisions.every((a) => a.includes(':')));
338
+ }
339
+ /** The branch a push's refspec updates on the remote: its destination, else its source, without `+` and `refs/heads/` (or `heads/`, which git reads the same). */
340
+ const pushedBranch = (refspec) => refspec.replace(/^\+/, '').split(':').pop().replace(/^(?:refs\/)?heads\//, '');
341
+ const isFile = (path) => {
342
+ try {
343
+ return statSync(path).isFile();
280
344
  }
281
- return values;
345
+ catch {
346
+ return false;
347
+ }
348
+ };
349
+ /** git diff's options that take the next word as their value. */
350
+ const DIFF_VALUE_OPTIONS = new Set([
351
+ '--src-prefix', '--dst-prefix', '--line-prefix', '-O', '--output-indicator-new', '--output-indicator-old', '--output-indicator-context',
352
+ '--diff-filter', '--anchored', '--ws-error-highlight', '--color-moved-ws', '--word-diff-regex', '-I', '--ignore-matching-lines',
353
+ '--inter-hunk-context', '--diff-algorithm', '--rotate-to', '--skip-to', '-l', '--stat-width', '--stat-name-width', '--stat-graph-width',
354
+ '--stat-count', '-S', '-G', '--find-object',
355
+ ]);
356
+ /** The paths a git diff of the working tree against the index names after its `--`, read as git reads its words; null when it names a commit or reads one (`--cached`), or may (a word before `--` that is not an option). */
357
+ function workingTreeFiles(args) {
358
+ for (let i = 0; i < args.length; i++) {
359
+ const a = args[i];
360
+ if (a === '--')
361
+ return args.slice(i + 1);
362
+ if (!a.startsWith('-') || ['--end-of-options', '--cached', '--staged', '--merge-base'].includes(a))
363
+ return null;
364
+ if (DIFF_VALUE_OPTIONS.has(a))
365
+ i++;
366
+ }
367
+ return [];
368
+ }
369
+ /** Whether every path names a file, in the working tree and in the index, which may still hold a folder of that name with a .env in it; false when git cannot say. */
370
+ function namesTrackedFiles(paths, cwd) {
371
+ if (!paths.length || !paths.every((a) => isFile(realPath(resolve(cwd, a)))))
372
+ return false;
373
+ const tracked = gitRead(['ls-files', '-z', '--', ...paths], cwd);
374
+ const named = new Set(paths.map((a) => resolve(cwd, a)));
375
+ return tracked !== null && tracked.split('\0').filter(Boolean).every((a) => named.has(resolve(cwd, a)));
282
376
  }
283
377
  function git(args, where, protectedBranches, held = false) {
284
- const [sub, ...rest] = args[0] === '--no-pager' ? args.slice(1) : args;
378
+ const words = gitArgs(args);
379
+ if (!words)
380
+ return deny('a git option before the command: run git with the command first');
381
+ const [sub, ...rest] = words;
285
382
  if (sub === 'status' || sub === 'diff' || sub === 'log' || sub === 'show' || sub === 'add') {
286
- const excluded = sub === 'add' && rest.includes(EXCLUDE_ENV);
383
+ // A diff, log or show takes the exclusion as a pathspec only after `--`; before it, an option such as --line-prefix may take it as its value.
384
+ const excluded = (sub === 'add' ? rest : rest.includes('--') ? rest.slice(rest.indexOf('--') + 1) : []).includes(EXCLUDE_ENV);
287
385
  const named = excluded ? rest.filter((a) => a !== EXCLUDE_ENV) : rest;
288
386
  // After `--` every word is a path, a dash in front too.
289
387
  const end = named.indexOf('--');
@@ -297,19 +395,54 @@ function git(args, where, protectedBranches, held = false) {
297
395
  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`);
298
396
  }
299
397
  // The pathspec leaves out the .env files under the folder the command runs in, and -A reaches past it.
300
- if (excluded && addsAll(named) && realPath(where.cwd) !== where.root)
398
+ if (sub === 'add' && excluded && addsAll(named) && realPath(where.cwd) !== where.root)
301
399
  return deny('a git add -A or -u from a folder below the top of the checkout: run it from the top');
400
+ const files = end < 0 ? [] : named.slice(end + 1);
401
+ // The exclusion reaches only the .env files below the folder the command runs in, and with no path named git reaches past that folder.
402
+ const positive = sub === 'add' ? paths : files;
403
+ if (excluded && realPath(where.cwd) !== where.root && (!positive.length || positive.some((a) => !within(realPath(resolve(where.cwd, a)), realPath(where.cwd))))) {
404
+ return deny(`below the top of the checkout, '${EXCLUDE_ENV}' leaves out only the .env files in that folder: name paths in it, or run the command from the top`);
405
+ }
406
+ // A patch shows a .env file the repository tracks, or the local values in one it tracks as a placeholder, unless it leaves them out.
407
+ if (sub !== 'add' && sub !== 'status' && !excluded && printsPatch(sub, named)) {
408
+ // A path that is a file today may have been a folder holding a .env in a commit, so only a diff of the working tree against the index may name files instead.
409
+ const worktree = sub === 'diff' ? workingTreeFiles(named) : null;
410
+ if (!worktree) {
411
+ return deny(`a git ${sub} that prints changes from a commit shows .env files too, under a path that is a file today as well: end the command with -- . '${EXCLUDE_ENV}'${sub === 'diff' ? ', or diff the working tree, with no commit, and name the files after --' : ''}`);
412
+ }
413
+ if (!namesTrackedFiles(worktree, where.cwd))
414
+ return deny(`a git diff that prints changes shows .env files too: name files the working tree and the index both hold as files after --, or end the command with -- . '${EXCLUDE_ENV}'`);
415
+ }
302
416
  return allow();
303
417
  }
304
418
  if (sub === 'commit') {
305
- 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)));
306
- return read === undefined ? allow() : deny('a commit that reads a file outside the checkout or the scratch folder, or a .env file');
419
+ const { files, commits, all, paths } = commitOptions(rest);
420
+ // The fence reads a file when it judges the command, and git, outside the sandbox, reads it again later: a command still running in the sandbox can swap it in between.
421
+ if (files.length)
422
+ return deny('a commit that reads its message, template or paths from a file, which a command in the sandbox can swap before git reads it: give the message with -m, and name the paths');
423
+ if (all)
424
+ return deny('a commit -a, which carries every change to a tracked file, a script\'s too: stage the files by name with git add');
425
+ if (paths.some((a) => /^:[(!^]|[*?[]/.test(a) || outside(a, where)))
426
+ return deny('a commit that names a pattern, or a path outside the checkout');
427
+ const read = commits.find((v) => /[*?[]/.test(v) || isSecretFile(v.slice(v.lastIndexOf(':') + 1)) || isSecretFile(realPath(resolve(where.cwd, v))) || (outside(v, where) && !inScratch(v, where)));
428
+ return read === undefined ? allow() : deny('a commit that reuses the message of a .env file, or of a commit named from outside the checkout');
429
+ }
430
+ if (sub === 'restore') {
431
+ // The way out of the index for what a refused commit would carry; the working tree stays as it is.
432
+ const end = rest.indexOf('--');
433
+ const options = (end < 0 ? rest : rest.slice(0, end)).filter((a) => a.startsWith('-'));
434
+ const paths = end < 0 ? rest.filter((a) => !a.startsWith('-')) : [...rest.slice(0, end).filter((a) => !a.startsWith('-')), ...rest.slice(end + 1)];
435
+ if (!options.length || !paths.length || options.some((a) => a !== '--staged' && a !== '-S'))
436
+ return deny('git restore takes --staged and the paths to take out of the index, nothing else');
437
+ if (paths.some((a) => /^:[(!^]|[*?[]/.test(a) || outside(a, where)))
438
+ return deny('a path outside the checkout, or a pattern');
439
+ return allow();
307
440
  }
308
441
  if (sub === 'checkout' && rest[0] === '-b' && rest.length >= 2 && rest.length <= 3)
309
442
  return allow();
310
443
  if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
311
444
  const branch = rest[2];
312
- if (branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch))
445
+ if (branch.includes(':') || ['HEAD', '@'].includes(pushedBranch(branch)) || protectedBranches.includes(pushedBranch(branch)))
313
446
  return deny('a push to the default branch');
314
447
  // A connected Vercel project builds every push, and calls a new project's first deployment Production, public at the project's own address.
315
448
  return held ? deny("a push before impeccable_vercel_connect settled the linked Vercel project's first deployment: run it first") : allow();
@@ -416,6 +549,11 @@ function reader(cmd, args, where) {
416
549
  // A recursive grep reads files nobody named, .env ones too, unless it leaves them out.
417
550
  if (recursive && !args.includes('--exclude=.env*'))
418
551
  return deny('a recursive grep reads .env files unless it has --exclude=.env*');
552
+ // grep takes the last of --include and --exclude that matches a file, so an --include (--inc too) after the exclude lets .env in again.
553
+ if (recursive && args.some((a) => /^--(?:inc|exclude-f)/.test(a)))
554
+ return deny('a recursive grep with --include or --exclude-from, which can let .env files in again: name the folders to search instead');
555
+ // An exclude's pattern, wherever it stands, is neither the search pattern nor a path.
556
+ paths = pathsOf(args.filter((a) => !/^--exclude(?:-dir)?=/.test(a)));
419
557
  if (!args.some((a) => a === '-e' || a === '-f' || a.startsWith('--regexp') || a.startsWith('--file')))
420
558
  paths = paths.slice(1);
421
559
  }
@@ -423,23 +561,27 @@ function reader(cmd, args, where) {
423
561
  if (paths.some((p) => outside(p, where) && !inScratch(p, where)))
424
562
  return deny('reads outside the checkout');
425
563
  // The shell expands `.e*` to .env too.
426
- if (cmd !== 'ls' && paths.some((p) => isSecretFile(p) || /^\.[^/]*[*?[]/.test(basename(p))))
564
+ if (cmd !== 'ls' && paths.some((p) => readsSecret(p, where) || /^\.[^/]*[*?[]/.test(basename(p))))
427
565
  return deny('reads a .env file, or a pattern that can match one');
428
566
  return allow();
429
567
  }
430
568
  /** 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. */
431
569
  const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
570
+ /** The version of each the run installs, raised with a release of this command: never the registry's latest of the day. */
571
+ export const HOST_CLI_VERSIONS = { 'netlify-cli': '27.11.2', vercel: '62.7.0' };
572
+ /** The one form of a host CLI's install that runs: npm's, global, of the pinned version. */
573
+ export const hostCliInstallCommand = (pkg) => `npm install -g ${pkg}@${HOST_CLI_VERSIONS[pkg]}`;
432
574
  /** git commit and push, gh and the host CLIs: what uses the person's GitHub, Vercel, Netlify or Cloudflare sign-in, keys or keychain. */
433
575
  function asPerson(words) {
434
576
  const [cmd = '', ...args] = words;
435
577
  if (cmd === 'git')
436
- return ['commit', 'push'].includes((args[0] === '--no-pager' ? args[1] : args[0]) ?? '');
578
+ return ['commit', 'push'].includes(gitArgs(args)?.[0] ?? '');
437
579
  return cmd === 'gh' || cmd === 'vercel' || cmd === 'netlify' || cmd === 'cf';
438
580
  }
439
581
  /** Where the command runs; null when it joins what runs as the person to what runs in the sandbox, which the fence refuses. cd and git's other commands, which need no sign-in, run on either side. */
440
582
  export function placeOf(command) {
441
583
  const commands = (parseCommand(command) ?? []).map(({ words }) => words.slice(envCount(words)));
442
- const installs = commands.filter(([pm = '', ...args]) => PACKAGE_MANAGERS.has(pm) && installsHostCli(pm, args, new Set(Object.keys(HOST_CLIS)))).length;
584
+ const installs = commands.filter(([pm = '', ...args]) => installsHostCli(pm, args)).length;
443
585
  if (installs)
444
586
  return installs === commands.length ? 'install' : null;
445
587
  const person = commands.filter(asPerson).length;
@@ -448,30 +590,41 @@ export function placeOf(command) {
448
590
  const either = commands.filter((words) => !asPerson(words) && (words[0] === 'cd' || words[0] === 'git')).length;
449
591
  return person + either === commands.length ? 'person' : null;
450
592
  }
451
- /** Whether this is a global install of a host's CLI and nothing else, the person having said yes to setting that host up. */
452
- function installsHostCli(pm, args, hosts) {
593
+ /** A package manager's global add of a host's CLI and nothing else, in any form: which host's, and the version or tag after its name when there is one. */
594
+ function hostCliAdd(pm, args) {
453
595
  const rest = args.filter((a) => !['-g', '--global', '--location=global'].includes(a));
454
596
  const global = pm === 'yarn' ? rest[0] === 'global' : rest.length < args.length;
455
597
  const [verb = '', name = '', ...more] = pm === 'yarn' ? rest.slice(1) : rest;
456
598
  const verbs = { npm: ['install', 'i', 'add'], pnpm: ['add'], yarn: ['add'], bun: ['add', 'install', 'i'] };
457
- const cli = name.replace(/@(latest|\d[\w.-]*)$/, '');
458
- return global && (verbs[pm] ?? []).includes(verb) && !more.length && Object.entries(HOST_CLIS).some(([host, pkg]) => pkg === cli && hosts.has(host));
599
+ if (!global || !(verbs[pm] ?? []).includes(verb) || more.length)
600
+ return null;
601
+ const at = name.indexOf('@', 1);
602
+ const pkg = at < 0 ? name : name.slice(0, at);
603
+ const host = Object.keys(HOST_CLIS).find((h) => HOST_CLIS[h] === pkg);
604
+ return host ? { host, pkg, ...(at < 0 ? {} : { spec: name.slice(at + 1) }) } : null;
605
+ }
606
+ /** Whether the add is the one form that runs: npm's, of the pinned version, so neither the registry's latest nor another package manager's store chooses what runs as the person. */
607
+ const pinned = (pm, add) => pm === 'npm' && add.spec !== undefined && add.spec === HOST_CLI_VERSIONS[add.pkg];
608
+ /** Whether this is the pinned npm install of a host's CLI and nothing else. */
609
+ function installsHostCli(pm, args) {
610
+ const add = PACKAGE_MANAGERS.has(pm) ? hostCliAdd(pm, args) : null;
611
+ return add !== null && pinned(pm, add);
459
612
  }
460
- /** The Netlify CLI's options that take a value, of those the rules below allow, so the value is not read as a command. */
613
+ /** The Netlify CLI's options that take a value, of those the rules below read, so the value is not read as a command. */
461
614
  const NETLIFY_VALUES = new Set(['--name', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
462
- /** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, a team's slug. */
615
+ /** What each of those values may be: a project's name, a GitHub repository, a team's slug. */
463
616
  const NETLIFY_VALID = {
464
617
  '--name': /^[A-Za-z0-9-]{1,63}$/,
465
- '--id': /^[0-9a-f-]{8,64}$/i,
466
618
  '--git-remote-url': /^(?:https:\/\/github\.com\/)?[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?(?:\.git)?$/,
467
- '--git-remote-name': /^[A-Za-z0-9._-]{1,50}$/,
468
619
  '--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
469
620
  };
470
- /** The Vercel project the folder is linked to, as `vercel link` wrote it in `.vercel/project.json`; null with none. */
621
+ /** The repository a `--git-remote-url` names, as owner/name, lowercased: `https://github.com/Acme/Crewline.git` is acme/crewline. */
622
+ const remoteRepository = (url) => url.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '').toLowerCase();
623
+ /** The Vercel project the folder is linked to, and its team, as `vercel link` wrote them in `.vercel/project.json`; null with none. */
471
624
  function linkedVercelProject(dir) {
472
625
  try {
473
- const id = JSON.parse(readFileSync(join(dir, '.vercel', 'project.json'), 'utf8')).projectId;
474
- return typeof id === 'string' ? id : null;
626
+ const { projectId, orgId } = JSON.parse(readFileSync(join(dir, '.vercel', 'project.json'), 'utf8'));
627
+ return typeof projectId === 'string' && typeof orgId === 'string' ? { projectId, orgId } : null;
475
628
  }
476
629
  catch {
477
630
  return null;
@@ -479,13 +632,16 @@ function linkedVercelProject(dir) {
479
632
  }
480
633
  /**
481
634
  * The Netlify CLI: `--version` and `status` at any time; once the person signed in to Netlify for the setup, the
482
- * project's link and creation, in the person's own current team or the one team they chose (`team`). The sign-in and
483
- * the repository's connection are the tools' (a `netlify login` outside the sign-in screen, and a `netlify init` that
484
- * asks its questions in a terminal, never run). Never a deploy, which builds the repository with the Netlify sign-in,
485
- * production or a project's removal, and nothing of its environment variables, its API (which reaches them, and prints
486
- * a site's password: impeccable_netlify_password runs it in the command's own process) or a token on the command line.
635
+ * project's creation, in the person's own current team or the one team they chose (`team`), and its link by
636
+ * `--git-remote-url` naming this repository (`repository`), which finds a site built from it: never a site by name or
637
+ * id, which would point one of the person's other sites at this repository. The sign-in and the repository's
638
+ * connection are the tools' (a `netlify login` outside the sign-in screen, and a `netlify init` that asks its questions
639
+ * in a terminal, never run). Never a deploy, which builds the repository with the Netlify sign-in, production or a
640
+ * project's removal, and nothing of its environment variables, its API (which reaches them, and prints a site's
641
+ * password: impeccable_netlify_password runs it in the command's own process) or a token on the command line.
487
642
  */
488
- function netlify(args, agreed, team) {
643
+ /** The Netlify CLI's arguments as its words, the subcommand first wherever its options stand, and its options, each with the value it takes. */
644
+ function netlifyArgs(args) {
489
645
  const words = [];
490
646
  const options = new Map();
491
647
  for (let i = 0; i < args.length; i++) {
@@ -500,6 +656,10 @@ function netlify(args, agreed, team) {
500
656
  const value = at > 0 ? arg.slice(at + 1) : NETLIFY_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
501
657
  options.set(name, value);
502
658
  }
659
+ return { words, options };
660
+ }
661
+ function netlify(args, agreed, team, repository) {
662
+ const { words, options } = netlifyArgs(args);
503
663
  const [sub = '', ...rest] = words;
504
664
  if (options.has('--auth'))
505
665
  return deny('puts a Netlify token on the command line');
@@ -524,8 +684,14 @@ function netlify(args, agreed, team) {
524
684
  if (options.has('--account-slug') && options.get('--account-slug') !== team)
525
685
  return deny('names a Netlify team the person did not choose');
526
686
  switch (sub) {
527
- case 'link':
528
- return !rest.length && options.size >= 1 && only('--name', '--id', '--git-remote-url', '--git-remote-name') ? allow() : deny('a netlify link off the allowlist');
687
+ case 'link': {
688
+ if (options.has('--name') || options.has('--id'))
689
+ return deny('a netlify link by name or id, which would point one of the person\'s sites at this repository: netlify link --git-remote-url finds a site built from it, and netlify sites:create makes one');
690
+ const remote = options.get('--git-remote-url');
691
+ if (!rest.length && options.size === 1 && only('--git-remote-url') && repository !== undefined && remoteRepository(remote) === repository.toLowerCase())
692
+ return allow();
693
+ return deny('a netlify link off the allowlist: --git-remote-url with this repository, and nothing else');
694
+ }
529
695
  case 'sites:create':
530
696
  return !rest.length && only('--name', '--json', '--account-slug') ? allow() : deny('a netlify sites:create off the allowlist');
531
697
  case 'deploy':
@@ -559,12 +725,14 @@ function vercelArgs(args) {
559
725
  }
560
726
  /**
561
727
  * The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
562
- * project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
563
- * the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
564
- * `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
565
- * environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
566
- * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
567
- * line.
728
+ * project's link, in the checkout's root alone (a folder named after one of the person's projects would link that
729
+ * project), its creation by name, and a preview deploy, in the one team the sign-in settled on (`team`): no other
730
+ * `--scope`, `--team` or `--project`, and no `project ls`, which names the person's projects. The sign-in and the Git
731
+ * connection are the tools' (a `vercel login` outside the sign-in screen never runs; impeccable_vercel_connect connects
732
+ * only a project this run created, or one connected to this repository already). Never production or a project's
733
+ * removal, and nothing of its environment variables, its protection settings (their bypass secrets print:
734
+ * impeccable_vercel_bypass runs those in the command's own process, so no secret reaches the model, and `api` and
735
+ * `curl` reach them too) or a token on the command line.
568
736
  */
569
737
  function vercel(args, where, agreed, team, settlement = null) {
570
738
  const { words, options } = vercelArgs(args);
@@ -590,18 +758,21 @@ function vercel(args, where, agreed, team, settlement = null) {
590
758
  const only = (...names) => [...options.keys()].every((n) => [...names, '--cwd', '--non-interactive'].includes(n));
591
759
  switch (sub) {
592
760
  case 'link':
761
+ // The link names the project after the folder it runs in, and links an existing project of that name: only the checkout's root, whose name is the repository's.
762
+ if (cwd !== undefined || realPath(where.cwd) !== where.root)
763
+ return deny('a vercel link in a folder of the checkout, which would link a project named after that folder: the link runs in the checkout\'s root');
593
764
  return !rest.length && only('--yes', '-y', ...VERCEL_TEAM) ? allow() : deny('a vercel link off the allowlist');
594
765
  case 'project':
595
- return ['add', 'ls', 'list'].includes(rest[0] ?? '') && rest.length <= 2 && only(...VERCEL_TEAM) ? allow() : deny('a vercel project command off the allowlist');
766
+ if (['ls', 'list'].includes(rest[0] ?? ''))
767
+ return deny("a vercel project ls, which names the person's projects");
768
+ return rest[0] === 'add' && rest.length <= 2 && only(...VERCEL_TEAM) ? allow() : deny('a vercel project command off the allowlist');
596
769
  case 'git':
597
- // Vercel could not reach the repository, which released the push as it built nothing: a connection now would let it build the project's first deployment, served publicly, so the tool that locks the project makes it.
598
- if (settlement?.how === 'connection')
599
- return deny('a vercel git connect after Vercel could not reach the repository: impeccable_vercel_connect connects it and locks a new project before anything is pushed');
600
- return rest.join(' ') === 'connect' && only(...VERCEL_TEAM) ? allow() : deny('a vercel git command other than connect');
770
+ // A connection makes the project build the repository: the tool makes it, for a project this run created or one connected to this repository already, and locks a new project before anything is pushed.
771
+ return deny('a vercel git connect from the shell: impeccable_vercel_connect connects the repository, to a project this run created alone, and locks a new project before anything is pushed');
601
772
  case 'deploy': {
602
773
  // A deploy builds the project the folder it runs in is linked to: the one --cwd names, else this one.
603
774
  const folder = resolve(where.cwd, typeof cwd === 'string' ? cwd : '.');
604
- if (!(settlement?.how === 'project' && settlement.projectId === linkedVercelProject(folder)))
775
+ if (!(settlement?.how === 'project' && settlement.projectId === (linkedVercelProject(folder)?.projectId ?? null)))
605
776
  return deny("a deploy before impeccable_vercel_connect settled the first deployment of the project this folder is linked to: a deploy from here builds it whether or not Vercel reaches the repository");
606
777
  return rest.length <= 1 && inside(rest, where) && only('--yes', '-y', '--target', ...VERCEL_TEAM) ? allow() : deny('a vercel deploy off the preview allowlist');
607
778
  }
@@ -642,7 +813,7 @@ function simpleCommand(seg, where, opts) {
642
813
  if (redirects.some((r) => outside(r.target, where) && !scratch(r)))
643
814
  return deny('reads outside the checkout');
644
815
  // 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.
645
- if (redirects.some((r) => r.op.includes('<') && isSecretFile(r.target)))
816
+ if (redirects.some((r) => r.op.includes('<') && readsSecret(r.target, where)))
646
817
  return deny('reads a .env file');
647
818
  if (!cmd)
648
819
  return deny(env.length ? 'sets environment variables and runs nothing' : 'a redirection on its own');
@@ -662,10 +833,14 @@ function simpleCommand(seg, where, opts) {
662
833
  return deny('a cd that runs only sometimes: put it first, or after ;');
663
834
  return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
664
835
  case 'git':
665
- return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === linkedVercelProject(where.root)));
836
+ return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === (linkedVercelProject(where.root)?.projectId ?? null)));
666
837
  case 'gh':
667
- if (args[0] === 'pr' && (args[1] === 'create' || args[1] === 'view'))
668
- return allow();
838
+ // gh reads the file outside the sandbox, after the fence read it: a command still running in the sandbox can swap it in between.
839
+ if (args[0] === 'pr' && args[1] === 'create')
840
+ return args.some((a) => /^-[a-zA-Z]*[FT]|^--(?:body-file|template)(?:=|$)/.test(a)) ? deny('a pull request body read from a file, which a command in the sandbox can swap before gh reads it: give it with --body') : allow();
841
+ // gh runs as the person, who can read other repositories' pull requests: this run's own, by number or branch, in this repository.
842
+ if (args[0] === 'pr' && args[1] === 'view')
843
+ return ghView(args.slice(2)) ? allow() : deny("gh pr view takes a pull request's number or this run's branch, --repo for this repository, --json and --comments");
669
844
  // 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.
670
845
  if (args[0] === 'pr' && args[1] === 'ready')
671
846
  return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
@@ -685,15 +860,19 @@ function simpleCommand(seg, where, opts) {
685
860
  case 'find':
686
861
  return reader(cmd, args, where);
687
862
  case 'netlify':
688
- return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'));
863
+ return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'), opts.repository);
689
864
  case 'vercel':
690
865
  return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
691
866
  case 'cf':
692
867
  return cloudflareCli(args);
693
- default:
694
- if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
695
- return allow('installs the CLI of the host the person said yes to');
696
- return PACKAGE_MANAGERS.has(cmd) ? packageManager(cmd, args, where, opts.scriptsChanged) : deny(`${cmd} is not on the allowlist`);
868
+ default: {
869
+ if (!PACKAGE_MANAGERS.has(cmd))
870
+ return deny(`${cmd} is not on the allowlist`);
871
+ const add = hostCliAdd(cmd, args);
872
+ if (add && opts.hosts.has(add.host))
873
+ return pinned(cmd, add) ? allow('installs the CLI of the host the person said yes to') : deny(`the run installs ${add.pkg} with npm, pinned to the version it knows: ${hostCliInstallCommand(add.pkg)}`);
874
+ return packageManager(cmd, args, where, opts.scriptsChanged);
875
+ }
697
876
  }
698
877
  }
699
878
  /** A refusal by a content check: a command naming a file that holds credentials, or text shaped like a secret where `sink` would take it off the machine. */
@@ -702,31 +881,74 @@ const found = (pattern, sink) => ({
702
881
  reason: pattern.context === 'command' ? `names ${pattern.description}, which holds credentials (${pattern.name})` : `${sink} holds text shaped like ${pattern.description} (${pattern.name})`,
703
882
  hit: { pattern, sink },
704
883
  });
705
- /** A file's text, when the command may read it (in the checkout or the scratch folder, and no .env file); null otherwise, or when it cannot be read. */
706
- function readable(path, where) {
707
- if (path.startsWith('~') || /[$*?[]/.test(path) || isSecretFile(path) || (outside(path, where) && !inScratch(path, where)))
708
- return null;
884
+ /** What git prints for `args`, run outside the sandbox and pointed at this checkout's own git folders; null when it cannot. */
885
+ function gitRead(args, cwd, { timeout = 10_000, settings = {}, input } = {}) {
709
886
  try {
710
- return readFileSync(resolve(where.cwd, path), 'utf8').slice(0, 1_000_000);
887
+ return execFileSync('git', args, { cwd, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], timeout, maxBuffer: 16 * 1024 * 1024, env: gitEnvironment({ ...process.env, ...settings }) });
711
888
  }
712
889
  catch {
713
890
  return null;
714
891
  }
715
892
  }
716
- /** The messages of the commits `revisions` name, as git log gives them; null when git cannot read them. */
717
- function commitMessages(revisions, cwd) {
718
- try {
719
- // The closing -- reads each as a commit even where a file of the same name is there.
720
- return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024, env: gitEnvironment() });
721
- }
722
- catch {
893
+ /** The messages of the commits `revisions` name, with more on git's input, as git log gives them; null when git cannot read them. */
894
+ function commitMessages(revisions, cwd, input) {
895
+ // The closing -- reads each as a commit even where a file of the same name is there.
896
+ return gitRead(['log', '--format=%B', ...revisions, '--'], cwd, { input });
897
+ }
898
+ /** git log's revisions for the commits `head` has that none of `bases` has (the whole branch when there are none): the bases go on its input, however many the remote has. */
899
+ const pastBases = (head, bases) => ({ revisions: ['--stdin', '--end-of-options', head], input: bases.map((base) => `^${base}\n`).join('') });
900
+ /** The messages of every commit `head` has that none of `bases` has: what --fill takes a pull request's title and body from, and what a push carries. Null when git cannot read them, never a part of them. */
901
+ function newMessages(head, bases, cwd) {
902
+ const { revisions, input } = pastBases(head, bases);
903
+ return commitMessages(revisions, cwd, input);
904
+ }
905
+ /**
906
+ * What the reads of the remote run with: nothing that asks for a password, a passphrase or a host's key, on the terminal, in a
907
+ * window or in Git Credential Manager's, which would hold the push until its minute ran out; a credential helper that answers on
908
+ * its own still does. SSH runs in batch mode unless the person set a command of their own for it, which is left as it is.
909
+ */
910
+ function unprompted(cwd) {
911
+ const ownSsh = process.env.GIT_SSH_COMMAND || process.env.GIT_SSH || gitRead(['config', '--get', 'core.sshCommand'], cwd);
912
+ return { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', ...(ownSsh ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }) };
913
+ }
914
+ /**
915
+ * What a `push -u origin <branch>` adds to, asked of the remote itself: the commits it has on that branch and on the default
916
+ * branch, as `origin/<branch>` would say, but that ref sits in the checkout's git folder, where a command in the sandbox can
917
+ * move it. Those two are fetched, so the range past them can be read here, and must be. Every other branch of the remote's
918
+ * whose commit is here counts too, since the remote has it and all before it: a test drive stacked on an earlier run's setup
919
+ * branch carries none of that branch's commits, also once the setup branch moved on. Null when the remote cannot be asked:
920
+ * what the push carries cannot be checked then, and it is refused. The reads go over the network, so they get the minute a
921
+ * push gets.
922
+ */
923
+ function pushBases(branch, defaultBranch, cwd) {
924
+ const settings = unprompted(cwd);
925
+ const read = gitRead(['ls-remote', '--heads', 'origin'], cwd, { timeout: 60_000, settings });
926
+ if (read === null)
723
927
  return null;
724
- }
928
+ const heads = new Map(read.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
929
+ const found = [`refs/heads/${pushedBranch(branch)}`, ...(defaultBranch ? [`refs/heads/${defaultBranch}`] : [])].filter((ref) => heads.has(ref));
930
+ if (!found.length)
931
+ return [];
932
+ // Fetched by ref, with no ref written here: a daemon moving one would change nothing of what is read.
933
+ gitRead(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...found], cwd, { timeout: 60_000, settings });
934
+ const bases = found.map((ref) => heads.get(ref));
935
+ const others = [...new Set(heads.values())].filter((sha) => !bases.includes(sha));
936
+ const known = others.length ? gitRead(['cat-file', '--batch-check=%(objectname) %(objecttype)'], cwd, { input: others.map((sha) => `${sha}\n`).join('') }) : '';
937
+ return [...bases, ...(known ?? '').split('\n').filter((line) => line.endsWith(' commit')).map((line) => line.split(' ')[0])];
938
+ }
939
+ const UNREAD_PUSH = 'the commits the push would carry past what the remote has could not be read here';
940
+ const UNMADE_PUSH = 'a push in the same command as the git commit or git checkout -b that makes what it carries, which is not there yet when the fence reads what the push would carry: run git push as a command of its own, after the commit';
941
+ const UNREACHED_PUSH = 'the remote could not be reached, so what the push would carry could not be checked: try the push again once the network is back';
942
+ /** The paths every commit `head` has that none of `bases` has touches, from the checkout's top: a file a later commit removes again is still in what the push sends, and a merge's own changes too. Null when git cannot read them. */
943
+ function newPaths(head, bases, cwd) {
944
+ const { revisions, input } = pastBases(head, bases);
945
+ const read = gitRead(['log', '--format=', '--name-only', '-m', '-z', '--no-renames', ...revisions, '--'], cwd, { input });
946
+ return read === null ? null : [...new Set(read.split('\0').filter(Boolean))];
725
947
  }
726
948
  /** The words of a git commit or a gh pr create that are its message, title or body: text, not paths. */
727
949
  function textWords(words) {
728
950
  const text = new Set();
729
- const commit = words[0] === 'git' && (words[1] === 'commit' || (words[1] === '--no-pager' && words[2] === 'commit'));
951
+ const commit = words[0] === 'git' && gitArgs(words.slice(1))?.[0] === 'commit';
730
952
  if (!commit && !(words[0] === 'gh' && words[1] === 'pr' && words[2] === 'create'))
731
953
  return text;
732
954
  words.forEach((w, i) => {
@@ -739,10 +961,10 @@ function textWords(words) {
739
961
  }
740
962
  /**
741
963
  * The content checks on a command the fence allows: a file that holds credentials, named anywhere in it but its message; and
742
- * text shaped like a secret in what it sends off the machine: a commit's message, or the file or commit it takes one from, and
743
- * a pull request's title, body, body file, or the commit messages --fill takes them from.
964
+ * text shaped like a secret in what it sends off the machine: a commit's message, or the commit it takes one from, the messages
965
+ * of the commits a push carries, and a pull request's title, body, or the commit messages --fill takes them from.
744
966
  */
745
- function contentCheck(seg, where, defaultBranch) {
967
+ function contentCheck(seg, where, defaultBranch, basesOf) {
746
968
  const text = textWords(seg.words);
747
969
  for (const [i, word] of [...seg.words, ...seg.redirects.map((r) => r.target)].entries()) {
748
970
  if (text.has(i))
@@ -753,25 +975,45 @@ function contentCheck(seg, where, defaultBranch) {
753
975
  return found(hit, 'the command');
754
976
  }
755
977
  const [cmd, ...rest] = seg.words;
756
- const args = cmd === 'git' && rest[0] === '--no-pager' ? rest.slice(1) : rest;
978
+ const args = cmd === 'git' ? gitArgs(rest) ?? [] : rest;
757
979
  const texts = [];
758
980
  if (cmd === 'git' && args[0] === 'commit') {
759
981
  texts.push(['the commit message', args.slice(1).join('\n')]);
760
- // -F reads a file, and -c and -C take another commit's message: a value may name both, a file and a commit, so both are read.
761
- for (const v of commitReads(args.slice(1)))
762
- texts.push(['the commit message file', readable(v, where)], ['the commit message it reuses', commitMessages(['-1', '--end-of-options', v], where.cwd)]);
982
+ for (const v of commitOptions(args.slice(1)).commits)
983
+ texts.push(['the commit message it reuses', commitMessages(['-1', '--end-of-options', v], where.cwd)]);
984
+ }
985
+ else if (cmd === 'git' && args[0] === 'push') {
986
+ // What the push carries: the commits the remote's branch does not have yet, or the default branch's when the remote has no such branch; a message was read when its commit was made, and is read again here, since a command in the sandbox can write commits of its own. A remote that cannot be asked refuses this attempt, and a later one asks again.
987
+ const bases = basesOf(args[3]);
988
+ if (bases === null)
989
+ return deny(UNREACHED_PUSH);
990
+ const carried = newMessages(args[3], bases, where.cwd);
991
+ if (carried === null)
992
+ return deny(UNREAD_PUSH);
993
+ texts.push(['the commit messages the push carries', carried]);
994
+ // A .env file's example a command wrote, which no write of pi's showed the fence: every line the push's commits add to one, read as a write of it is.
995
+ const { revisions, input } = pastBases(args[3], bases);
996
+ const examples = gitRead(['log', '--format=', '-p', '-m', '--no-renames', '--text', '--no-textconv', '--no-ext-diff', '--no-color', ...revisions, '--', ...ENV_EXAMPLES], where.cwd, { input });
997
+ if (examples === null)
998
+ return deny(UNREAD_PUSH);
999
+ const added = examples.split('\n').filter((line) => line.startsWith('+')).join('\n');
1000
+ // As a write of one is read: as written, and as the screen would show it once an escape sequence is taken out.
1001
+ const read = (text) => scan('output', text).find((p) => !PLACEHOLDER_SHAPES.has(p.name));
1002
+ const plain = plainText(added);
1003
+ const hit = read(added) ?? (plain === added ? undefined : read(plain));
1004
+ if (hit)
1005
+ return found(hit, 'a .env example the push carries');
763
1006
  }
764
1007
  else if (cmd === 'gh' && args[0] === 'pr' && args[1] === 'create') {
765
1008
  const parsed = createArguments(args.slice(2));
766
1009
  texts.push(["the pull request's title or body", args.slice(2).join('\n')]);
767
- if (parsed?.bodyFile)
768
- texts.push(["the pull request's body file", readable(parsed.bodyFile, where)]);
769
1010
  if (parsed?.filled) {
770
- // What gh fills the title and body from: the commits on the head that the base does not have, or the newest hundred when git cannot tell which.
1011
+ // What gh fills the title and body from: the commits on the head that the base does not have. A head git has whose commits cannot be read here is refused: gh, reading the same refs, could not fill from it either.
771
1012
  const base = parsed.base ?? defaultBranch, head = parsed.head ?? 'HEAD';
772
- const ranges = [...(base ? [`origin/${base}..${head}`, `${base}..${head}`].map((range) => ['--end-of-options', range]) : []), ['-n', '100', '--end-of-options', head]];
773
- const messages = ranges.reduce((read, range) => read ?? commitMessages(range, where.cwd), null);
774
- texts.push(["the commit messages --fill takes the pull request's title and body from", messages]);
1013
+ const filled = base ? newMessages(head, [`origin/${base}`], where.cwd) ?? newMessages(head, [base], where.cwd) : newMessages(head, [], where.cwd);
1014
+ if (filled === null && gitRead(['rev-parse', '--verify', '--quiet', '--end-of-options', `${head}^{commit}`], where.cwd) !== null)
1015
+ return deny("the commit messages --fill takes the pull request's title and body from could not be read here");
1016
+ texts.push(["the commit messages --fill takes the pull request's title and body from", filled]);
775
1017
  }
776
1018
  }
777
1019
  for (const [sink, text] of texts) {
@@ -781,20 +1023,163 @@ function contentCheck(seg, where, defaultBranch) {
781
1023
  }
782
1024
  return null;
783
1025
  }
1026
+ /** A path as git names it, from the checkout's top: as written (the folders on the way resolved, the file itself not, since git tracks a link by its name) and with every symlink resolved, when that differs. */
1027
+ function fromTop(at, where) {
1028
+ const forms = [resolve(realPath(dirname(at)), basename(at)), realPath(at)].filter((form) => within(form, where.root)).map((form) => relative(where.root, form).split(sep).join('/'));
1029
+ return [...new Set(forms)];
1030
+ }
1031
+ /** The path pi's write or edit tool wrote for the model's string, as git names it from the checkout's top. */
1032
+ function writtenPaths(path, root) {
1033
+ return fromTop(resolveToCwd(path, root), { root: realPath(root), cwd: root });
1034
+ }
1035
+ /** What a git add or commit names, from the checkout's top: the files, and the folders (`.` for -A and -u) whose every change it sweeps in. */
1036
+ function namesOf(words, where) {
1037
+ const [sub = '', ...rest] = gitArgs(words.slice(1)) ?? [];
1038
+ let given;
1039
+ if (sub === 'add') {
1040
+ const named = rest.filter((a) => a !== EXCLUDE_ENV);
1041
+ const end = named.indexOf('--');
1042
+ given = end < 0 ? pathsOf(named) : [...pathsOf(named.slice(0, end)), ...named.slice(end + 1)];
1043
+ if (addsAll(named))
1044
+ given.push('.');
1045
+ }
1046
+ else if (sub === 'commit') {
1047
+ given = commitOptions(rest).paths;
1048
+ }
1049
+ else {
1050
+ return { files: [], folders: [] };
1051
+ }
1052
+ const files = [], folders = [];
1053
+ for (const path of given) {
1054
+ const at = resolve(where.cwd, path);
1055
+ (isFolder(realPath(at)) ? folders : files).push(...fromTop(at, where));
1056
+ }
1057
+ return { files, folders };
1058
+ }
1059
+ /** The files a command the fence allowed names in its git adds and commits, from the checkout's top, each cd followed: the run's own from then on. */
1060
+ function namedPaths(command, root) {
1061
+ const where = { root: realPath(root), cwd: root };
1062
+ const files = [];
1063
+ for (const { words } of parseCommand(command) ?? []) {
1064
+ if (words[0] === 'cd')
1065
+ where.cwd = resolve(where.cwd, words[1] ?? '.');
1066
+ else if (words[0] === 'git')
1067
+ files.push(...namesOf(words, where).files);
1068
+ }
1069
+ return files;
1070
+ }
1071
+ /**
1072
+ * The paths a commit would carry besides the files the command names: staged against HEAD already, and swept in by the
1073
+ * folders the command adds first. Null when git cannot read the index.
1074
+ */
1075
+ function carriedPaths(where, named) {
1076
+ const staged = gitRead(['diff', '--cached', '--name-only', '-z'], where.root);
1077
+ if (staged === null)
1078
+ return null;
1079
+ const carried = new Set(staged.split('\0').filter(Boolean));
1080
+ for (const folder of named.folders) {
1081
+ const swept = gitRead(['status', '--porcelain', '-z', '--untracked-files=all', '--no-renames', '--', ...(folder ? [folder] : [])], where.root);
1082
+ if (swept === null)
1083
+ return null;
1084
+ // The add's exclusion leaves the .env files out.
1085
+ for (const entry of swept.split('\0'))
1086
+ if (entry.length > 3 && !isSecretFile(entry.slice(3)))
1087
+ carried.add(entry.slice(3));
1088
+ }
1089
+ return carried;
1090
+ }
1091
+ /**
1092
+ * What a commit takes at `path`, from the working tree or from the index: the sha256 of a plain file's bytes, null when there
1093
+ * is none, and `other` for anything else (a symlink, which git commits as the name it points to, or an index entry git cannot read).
1094
+ */
1095
+ function committedAt(path, where, staged) {
1096
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
1097
+ if (!staged) {
1098
+ try {
1099
+ return lstatSync(join(where.root, path)).isFile() ? digest(readFileSync(join(where.root, path))) : 'other';
1100
+ }
1101
+ catch (error) {
1102
+ return error.code === 'ENOENT' ? null : 'other';
1103
+ }
1104
+ }
1105
+ const entry = gitRead(['ls-files', '--stage', '-z', '--', path], where.root);
1106
+ if (entry === '')
1107
+ return null;
1108
+ const blob = /^100(?:644|755) ([0-9a-f]+) 0\t/.exec(entry ?? '')?.[1];
1109
+ try {
1110
+ return blob ? digest(execFileSync('git', ['cat-file', 'blob', blob], { cwd: where.root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024, env: gitEnvironment() })) : 'other';
1111
+ }
1112
+ catch {
1113
+ return 'other';
1114
+ }
1115
+ }
1116
+ /**
1117
+ * The workflow files impeccable_sync_workflow wrote or removed that a commit would leave otherwise than it did: what an add in
1118
+ * the command takes, from the working tree, else what is staged. One the commit does not carry is not read.
1119
+ */
1120
+ function unsyncedWorkflows(where, named, carried, synced) {
1121
+ return [...synced].filter(([path, sha256]) => {
1122
+ const added = named.files.has(path) || named.folders.some((folder) => !folder || path.startsWith(`${folder}/`));
1123
+ return (added || carried.has(path)) && committedAt(path, where, !added) !== sha256;
1124
+ }).map(([path]) => path);
1125
+ }
784
1126
  /** The fence's answer for one shell command run from the checkout's top folder. */
785
1127
  export function classifyCommand(command, root, options = {}) {
786
- const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null };
1128
+ const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
787
1129
  const segments = parseCommand(command);
788
1130
  if (!segments)
789
1131
  return deny('uses shell syntax the fence does not read');
790
1132
  if (!segments.length)
791
1133
  return deny('an empty command');
792
1134
  const where = { root: realPath(root), cwd: root, scratch: options.scratch && realPath(options.scratch) };
1135
+ const named = { files: new Set(), folders: [] };
1136
+ // A push's messages and paths are read against the same answer of the remote, asked once.
1137
+ const remote = new Map();
1138
+ const basesOf = (branch) => {
1139
+ if (!remote.has(branch))
1140
+ remote.set(branch, pushBases(branch, opts.protectedBranches[0], where.cwd));
1141
+ return remote.get(branch);
1142
+ };
793
1143
  let result = allow();
1144
+ /** A git commit or checkout -b earlier in the command, whose commits or branch are not there yet when a push after it is judged. */
1145
+ let unmade = false;
794
1146
  for (const seg of segments) {
795
1147
  let decision = simpleCommand(seg, where, opts);
1148
+ const gitSub = seg.words[0] === 'git' ? gitArgs(seg.words.slice(1))?.[0] : undefined;
1149
+ if (decision.verdict === 'allow' && gitSub === 'push' && unmade)
1150
+ decision = deny(UNMADE_PUSH);
1151
+ if (gitSub === 'commit' || gitSub === 'checkout')
1152
+ unmade = true;
796
1153
  if (decision.verdict === 'allow')
797
- decision = contentCheck(seg, where, opts.protectedBranches[0]) ?? decision;
1154
+ decision = contentCheck(seg, where, opts.protectedBranches[0], basesOf) ?? decision;
1155
+ if (decision.verdict === 'allow' && options.touched && seg.words[0] === 'git') {
1156
+ const names = namesOf(seg.words, where);
1157
+ names.files.forEach((file) => named.files.add(file));
1158
+ named.folders.push(...names.folders);
1159
+ const list = (paths) => `${paths.slice(0, 3).join(', ')}${paths.length > 3 ? ` and ${paths.length - 3} more` : ''}`;
1160
+ if (gitSub === 'commit') {
1161
+ const carried = carriedPaths(where, named);
1162
+ const unnamed = carried && [...carried].filter((path) => !options.touched.has(path) && !named.files.has(path));
1163
+ const unsynced = carried && options.synced ? unsyncedWorkflows(where, named, carried, options.synced) : [];
1164
+ if (unnamed === null)
1165
+ decision = deny('the paths the commit would carry could not be read');
1166
+ else if (unnamed.length)
1167
+ decision = deny(`a commit that carries ${list(unnamed)}, which this run never wrote or added by name: git restore --staged <path> takes a file out of the commit, and git add <path> puts in one the run meant to change`);
1168
+ else if (unsynced.length)
1169
+ decision = deny(`a commit that carries ${list(unsynced)} otherwise than impeccable_sync_workflow left it, though only that tool writes the workflow: call it again, then stage the file by name`);
1170
+ }
1171
+ else if (gitSub === 'push') {
1172
+ // A command in the sandbox can commit on the branch itself, past the index: what the push adds is read against what the remote has, as its messages are, and what the remote has must be readable here.
1173
+ const branch = seg.words.at(-1);
1174
+ const bases = basesOf(branch);
1175
+ const carried = bases && newPaths(branch, bases, where.cwd);
1176
+ const unnamed = carried?.filter((path) => !options.touched.has(path));
1177
+ if (!unnamed)
1178
+ decision = deny(bases ? UNREAD_PUSH : UNREACHED_PUSH);
1179
+ else if (unnamed?.length)
1180
+ decision = deny(`a push that carries ${list(unnamed)}, which this run never wrote or added by name: a script in the sandbox committed it`);
1181
+ }
1182
+ }
798
1183
  if (decision.verdict === 'never')
799
1184
  return decision;
800
1185
  if (decision.verdict === 'deny' && result.verdict === 'allow')
@@ -899,23 +1284,257 @@ export function classifyPreviewCommand(command, root, options = {}) {
899
1284
  }
900
1285
  return allow('inside the disposable preview verifier');
901
1286
  }
902
- /** The fence's answer for pi's file tools: writes stay in the checkout and out of every .git, the checkout's and any other a file could make; reads stay in it or in the run's scratch folder, and off .env files. */
903
- export function classifyFileTool(tool, path, root, scratch) {
1287
+ /** A path pi spells into another before opening it: `@` (its file-mention prefix) comes off, `~` is the home folder, and a URL is a path. */
1288
+ const SPELLED = /^[@~]|^[a-z][a-z0-9+.-]*:\/\//i;
1289
+ /** The names .env files take. ripgrep's `*` matches a leading dot, unlike the shell's, so the grep tool's glob is held against these, not read for a dot. */
1290
+ const ENV_NAMES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local', '.env.staging'];
1291
+ /**
1292
+ * A glob's alternatives with its braces expanded, `{a,b}c` being `ac` and `bc`, a brace in a class (`[{]`) or escaped (`\{`) being
1293
+ * the character; null for an unclosed brace or more than 64 alternatives, which counts as naming .env files. One group is opened
1294
+ * per pass over a loop, never a call, so nesting costs no stack.
1295
+ */
1296
+ function expandBraces(glob) {
1297
+ const done = [];
1298
+ const pending = [glob];
1299
+ while (pending.length) {
1300
+ const s = pending.pop();
1301
+ const parts = [];
1302
+ let depth = 0, open = 0, from = 0, expanded = false;
1303
+ for (let i = 0; i < s.length && !expanded; i++) {
1304
+ const c = s[i];
1305
+ if (c === '\\')
1306
+ i++;
1307
+ else if (c === '[') {
1308
+ const end = s.indexOf(']', i + 1);
1309
+ i = end < 0 ? s.length : end;
1310
+ }
1311
+ else if (c === '{') {
1312
+ if (depth++ === 0)
1313
+ (open = i, from = i + 1);
1314
+ }
1315
+ else if (c === '}' && depth) {
1316
+ if (--depth === 0) {
1317
+ parts.push(s.slice(from, i));
1318
+ for (const part of parts)
1319
+ pending.push(s.slice(0, open) + part + s.slice(i + 1));
1320
+ expanded = true;
1321
+ }
1322
+ }
1323
+ else if (c === ',' && depth === 1)
1324
+ (parts.push(s.slice(from, i)), from = i + 1);
1325
+ }
1326
+ if (!expanded) {
1327
+ if (depth)
1328
+ return null;
1329
+ done.push(s);
1330
+ }
1331
+ if (done.length + pending.length > 64)
1332
+ return null;
1333
+ }
1334
+ return done;
1335
+ }
1336
+ /** Whether a brace-free glob matches a name: `*` any run, `?` one character, `[...]` a class, `\` the next character itself. Two pointers, so the work is bounded by the lengths however many wildcards there are. */
1337
+ function globMatches(glob, name) {
1338
+ let g = 0, n = 0, star = -1, mark = 0;
1339
+ while (n < name.length) {
1340
+ if (g < glob.length) {
1341
+ const c = glob[g];
1342
+ if (c === '*') {
1343
+ star = g++;
1344
+ mark = n;
1345
+ continue;
1346
+ }
1347
+ let next = g + 1;
1348
+ let ok;
1349
+ if (c === '?')
1350
+ ok = true;
1351
+ else if (c === '[') {
1352
+ const end = glob.indexOf(']', g + 1);
1353
+ if (end < 0)
1354
+ return true;
1355
+ ok = new RegExp(`^[${glob.slice(g + 1, end).replace(/^!/, '^')}]$`).test(name[n]);
1356
+ next = end + 1;
1357
+ }
1358
+ else if (c === '\\') {
1359
+ ok = glob[g + 1] === name[n];
1360
+ next = g + 2;
1361
+ }
1362
+ else
1363
+ ok = c === name[n];
1364
+ if (ok) {
1365
+ g = next;
1366
+ n++;
1367
+ continue;
1368
+ }
1369
+ }
1370
+ if (star < 0)
1371
+ return false;
1372
+ g = star + 1;
1373
+ n = ++mark;
1374
+ }
1375
+ while (glob[g] === '*')
1376
+ g++;
1377
+ return g === glob.length;
1378
+ }
1379
+ /** Whether a ripgrep glob names .env files, by its last segment (`*`, `**`, `?`, `[...]`, `{a,b}` and `\` as ripgrep reads them); `*` and `**` alone take every file, a folder search, whose report is read instead. */
1380
+ function globMatchesEnv(glob) {
1381
+ const segment = glob.replace(/\/+$/, '').split('/').pop() ?? '';
1382
+ if (segment === '*' || segment === '**')
1383
+ return false;
1384
+ // A segment past a kilobyte names no file; with the length bounded, so is every pass below.
1385
+ if (segment.length > 1024)
1386
+ return true;
1387
+ const alternatives = expandBraces(segment);
1388
+ try {
1389
+ return !alternatives || alternatives.some((alternative) => ENV_NAMES.some((name) => globMatches(alternative, name)));
1390
+ }
1391
+ catch {
1392
+ return true;
1393
+ }
1394
+ }
1395
+ /**
1396
+ * The fence's answer for pi's file tools, which open files in the command's own process, as the person: writes stay in the
1397
+ * checkout and out of every .git, the checkout's and any other a file could make; reads stay in it or in the run's scratch
1398
+ * folder, and off .env files. The name typed is judged, then the path pi resolves it to, the way pi does, symlinks followed.
1399
+ */
1400
+ export function classifyFileTool(tool, path, root, scratch, glob) {
904
1401
  const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
905
1402
  const p = typeof path === 'string' && path ? path : '.';
906
- if (tool === 'write' || tool === 'edit') {
1403
+ const writes = tool === 'write' || tool === 'edit';
1404
+ if (SPELLED.test(p))
1405
+ return (writes ? never : deny)('a path with @, ~ or a URL scheme in front, which pi opens as another file: name the file by its path');
1406
+ const opened = tool === 'read' ? resolveReadPath(p, root) : resolveToCwd(p, root);
1407
+ if (writes) {
907
1408
  if (outside(p, where))
908
1409
  return never('writes outside the checkout');
909
- // A .git file or folder below the top would be where git takes its settings and hooks from there.
910
- const inGit = [p, relative(where.root, realPath(resolve(root, p)))].some((q) => q.split(/[\\/]/).includes('.git'));
911
- return inGit ? never('writes into .git, where hooks run') : allow();
1410
+ if (p.split(/[\\/]/).includes('.git'))
1411
+ return never('writes into .git, where hooks run');
1412
+ return classifyOpened(tool, opened, where);
912
1413
  }
913
1414
  if (outside(p, where) && !inScratch(p, where))
914
1415
  return deny('reads outside the checkout');
915
- if ((tool === 'read' || tool === 'grep') && isSecretFile(p))
1416
+ if ((tool === 'read' || tool === 'grep') && readsSecret(p, where))
1417
+ return deny('reads a .env file');
1418
+ if (tool === 'grep' && typeof glob === 'string' && globMatchesEnv(glob))
1419
+ return deny('a grep glob that names .env files: search the folder by its path instead');
1420
+ return classifyOpened(tool, opened, where);
1421
+ }
1422
+ /** A symlink to a file that is not there: a write through it makes the target, wherever it points. */
1423
+ const dangling = (path) => {
1424
+ try {
1425
+ return lstatSync(path).isSymbolicLink() && !existsSync(path);
1426
+ }
1427
+ catch {
1428
+ return false;
1429
+ }
1430
+ };
1431
+ /** The fence's answer for the path a file tool resolved and opens, the same rules on what it stands for. */
1432
+ function classifyOpened(tool, opened, where) {
1433
+ const real = realPath(opened);
1434
+ if (tool === 'write' || tool === 'edit') {
1435
+ if (!within(real, where.root))
1436
+ return never('writes outside the checkout');
1437
+ if (dangling(opened))
1438
+ return never('writes through a symlink to a file that is not there');
1439
+ // A .git file or folder below the top would be where git takes its settings and hooks from there.
1440
+ return relative(where.root, real).split(/[\\/]/).includes('.git') ? never('writes into .git, where hooks run') : allow();
1441
+ }
1442
+ if (!within(real, where.root) && !(where.scratch && within(real, where.scratch)))
1443
+ return deny('reads outside the checkout');
1444
+ if ((tool === 'read' || tool === 'grep') && secretOnPath(real, where))
916
1445
  return deny('reads a .env file');
917
1446
  return allow();
918
1447
  }
1448
+ /**
1449
+ * What pi's read, write, edit, grep, ls and find tools open files with: each path, as pi resolved it, is judged once more at the file
1450
+ * itself, so what the tool opens is never past what the fence allowed, whatever spelling the model's string took. grep's are the folder
1451
+ * it searches and the files it reads context lines from; the matches themselves ripgrep reads, and guardedGrepTool takes those out.
1452
+ * ls's are the folder and each entry in it, so an entry that is a symlink out of the checkout is skipped, not listed.
1453
+ */
1454
+ export function guardedFileOperations(root, scratch) {
1455
+ const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
1456
+ // Every operation judges its own path: the order pi calls them in is not relied on.
1457
+ const judged = (tool, path) => {
1458
+ const decision = classifyOpened(tool, path, where);
1459
+ if (decision.verdict !== 'allow')
1460
+ throw new Error(`Refused: ${decision.reason}`);
1461
+ return path;
1462
+ };
1463
+ return {
1464
+ read: {
1465
+ readFile: async (path) => readFile(judged('read', path)),
1466
+ access: async (path) => access(judged('read', path), constants.R_OK),
1467
+ detectImageMimeType: async (path) => detectSupportedImageMimeTypeFromFile(judged('read', path)),
1468
+ },
1469
+ write: {
1470
+ writeFile: async (path, content) => writeFile(judged('write', path), content, 'utf-8'),
1471
+ mkdir: async (dir) => void (await mkdir(judged('write', dir), { recursive: true })),
1472
+ },
1473
+ edit: {
1474
+ readFile: async (path) => readFile(judged('edit', path)),
1475
+ writeFile: async (path, content) => writeFile(judged('edit', path), content, 'utf-8'),
1476
+ access: async (path) => access(judged('edit', path), constants.R_OK | constants.W_OK),
1477
+ },
1478
+ grep: {
1479
+ isDirectory: async (path) => (await stat(judged('grep', path))).isDirectory(),
1480
+ readFile: async (path) => readFile(judged('grep', path), 'utf-8'),
1481
+ },
1482
+ ls: {
1483
+ exists: async (path) => existsSync(judged('ls', path)),
1484
+ stat: async (path) => stat(judged('ls', path)),
1485
+ readdir: async (path) => readdir(judged('ls', path)),
1486
+ },
1487
+ find: (path) => classifyOpened('find', path, where),
1488
+ };
1489
+ }
1490
+ /**
1491
+ * A grep report without the lines of .env files: ripgrep reads the files itself, and a glob the model gives brings a file back past
1492
+ * any ignore rule, so what it reported is read instead. Each line starts with the file's path, then `:<line>: ` or `-<line>- `;
1493
+ * every split that reads so is tried, so a folder named to look like one hides nothing, and a line of another file that reads like
1494
+ * a .env file's goes too.
1495
+ */
1496
+ export function withoutEnvLines(report) {
1497
+ const kept = report.split('\n').filter((line) => ![...line.matchAll(/[:-]\d+[:-] /g)].some((m) => isSecretFile(line.slice(0, m.index))));
1498
+ return kept.length ? kept.join('\n') : 'No matches found';
1499
+ }
1500
+ /**
1501
+ * pi's find tool, with the folder it searches judged before fd walks it and every path fd reports judged after: find's operations
1502
+ * can only replace fd (and with it the .gitignore rules it keeps), so this is where its containment lives. fd does not follow a
1503
+ * symlink it meets, only one it starts from, which the first check refuses; the second takes out a symlink that points outside,
1504
+ * and holds should fd ever follow one. The names of .env files stay, as `ls -a` in the sandbox shows them: only reads are refused.
1505
+ */
1506
+ export function guardedFindTool(cwd, judge) {
1507
+ const find = createFindToolDefinition(cwd);
1508
+ return {
1509
+ ...find,
1510
+ execute: async (id, params, signal, onUpdate, ctx) => {
1511
+ const searchPath = resolveToCwd(params.path || '.', ctx?.cwd || cwd);
1512
+ const root = judge(searchPath);
1513
+ if (root.verdict !== 'allow')
1514
+ throw new Error(`Refused: ${root.reason}`);
1515
+ const result = await find.execute(id, params, signal, onUpdate, ctx);
1516
+ // One path under searchPath a line, no line empty, then a blank line and the notices; "No files found" names nothing, so it stays.
1517
+ const contained = (text) => {
1518
+ const [list, notices] = text.split('\n\n');
1519
+ const kept = list.split('\n').filter((line) => judge(join(searchPath, line)).verdict === 'allow');
1520
+ // The limit notice stays even when every path went: what fd stopped at may hide matches a higher limit would reach.
1521
+ return [kept.length ? kept.join('\n') : 'No files found matching pattern', notices].filter(Boolean).join('\n\n');
1522
+ };
1523
+ return { ...result, content: result.content.map((c) => (c.type === 'text' ? { ...c, text: contained(c.text) } : c)) };
1524
+ },
1525
+ };
1526
+ }
1527
+ /** pi's grep tool, opening files through `operations` and reporting no line of a .env file. */
1528
+ export function guardedGrepTool(cwd, operations) {
1529
+ const grep = createGrepToolDefinition(cwd, { operations });
1530
+ return {
1531
+ ...grep,
1532
+ execute: async (...args) => {
1533
+ const result = await grep.execute(...args);
1534
+ return { ...result, content: result.content.map((c) => (c.type === 'text' ? { ...c, text: withoutEnvLines(c.text) } : c)) };
1535
+ },
1536
+ };
1537
+ }
919
1538
  const previewRetries = new WeakMap();
920
1539
  export function previewRetryState(log) {
921
1540
  let state = previewRetries.get(log);
@@ -927,6 +1546,8 @@ export function previewRetryState(log) {
927
1546
  }
928
1547
  const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
929
1548
  const driveBranch = (branch) => /^impeccable\/test-drive(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
1549
+ /** A GitHub repository as `<owner>/<name>`. */
1550
+ const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/;
930
1551
  /** `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. */
931
1552
  function ghReady(args) {
932
1553
  const [branch, ...rest] = args;
@@ -936,15 +1557,42 @@ function ghReady(args) {
936
1557
  return { branch };
937
1558
  const inline = rest[0]?.startsWith('--repo=') ? rest[0].slice('--repo='.length) : undefined;
938
1559
  const repository = inline ?? (rest[0] === '--repo' || rest[0] === '-R' ? rest[1] : undefined);
939
- 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;
1560
+ return rest.length === (inline !== undefined ? 1 : 2) && typeof repository === 'string' && REPOSITORY.test(repository) ? { branch, repository } : null;
940
1561
  }
941
- const pushBranch = (words, literal = setupBranch) => words.length === 5 && words[0] === 'git' && words[1] === 'push'
942
- && ['-u', '--set-upstream'].includes(words[2]) && words[3] === 'origin' && literal(words[4]) ? words[4] : undefined;
1562
+ /** `gh pr view [<number> | this run's branch] [--repo <owner>/<name>] [--json <fields>] [--comments]`, each once: the repository named, or null. */
1563
+ function ghView(args) {
1564
+ const seen = new Map();
1565
+ for (let i = 0; i < args.length; i++) {
1566
+ const arg = args[i];
1567
+ const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
1568
+ const flag = at < 0 ? arg : arg.slice(0, at);
1569
+ const name = flag === '-R' ? '--repo' : flag === '-c' ? '--comments' : flag;
1570
+ let value = '';
1571
+ if (name === '--repo' || name === '--json') {
1572
+ value = (at < 0 ? args[++i] : arg.slice(at + 1)) ?? '';
1573
+ if (!value || value.startsWith('-'))
1574
+ return null;
1575
+ }
1576
+ else if (name !== '--comments' || at >= 0) {
1577
+ if (arg.startsWith('-') || !(/^[1-9]\d*$/.test(arg) || setupBranch(arg) || driveBranch(arg)))
1578
+ return null;
1579
+ }
1580
+ const key = arg.startsWith('-') ? name : 'selector';
1581
+ if (seen.has(key))
1582
+ return null;
1583
+ seen.set(key, value);
1584
+ }
1585
+ const repository = seen.get('--repo');
1586
+ return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
1587
+ }
1588
+ /** The branch of `push -u origin <branch>`, git's words from its command on, when `literal` takes it. */
1589
+ const pushBranch = (args, literal = setupBranch) => args.length === 4 && args[0] === 'push'
1590
+ && ['-u', '--set-upstream'].includes(args[1]) && args[2] === 'origin' && literal(args[3]) ? args[3] : undefined;
943
1591
  function createArguments(args) {
944
1592
  const values = new Map();
945
1593
  const names = {
946
1594
  '-H': 'head', '--head': 'head', '-R': 'repo', '--repo': 'repo', '-B': 'base', '--base': 'base',
947
- '-t': 'title', '--title': 'title', '-b': 'body', '--body': 'body', '-F': 'body-file', '--body-file': 'body-file',
1595
+ '-t': 'title', '--title': 'title', '-b': 'body', '--body': 'body',
948
1596
  '-a': 'assignee', '--assignee': 'assignee', '-r': 'reviewer', '--reviewer': 'reviewer',
949
1597
  '-l': 'label', '--label': 'label', '-m': 'milestone', '--milestone': 'milestone', '-p': 'project', '--project': 'project',
950
1598
  };
@@ -966,16 +1614,12 @@ function createArguments(args) {
966
1614
  return;
967
1615
  values.set(name, value);
968
1616
  }
969
- const head = values.get('head'), bodyFile = values.get('body-file');
1617
+ const head = values.get('head');
970
1618
  if (head !== undefined && !setupBranch(head) && !driveBranch(head))
971
1619
  return;
972
- if (!filled && (!values.get('title')?.trim() || (!values.has('body') && !values.has('body-file'))))
973
- return;
974
- if (values.has('body') && values.has('body-file'))
975
- return;
976
- if (values.has('body-file') && (!bodyFile || bodyFile === '-'))
1620
+ if (!filled && (!values.get('title')?.trim() || !values.has('body')))
977
1621
  return;
978
- return { head, repository: values.get('repo'), bodyFile, base: values.get('base'), filled };
1622
+ return { head, repository: values.get('repo'), base: values.get('base'), filled };
979
1623
  }
980
1624
  /** Called synchronously only after the ordinary fence allows the entire command. */
981
1625
  export function reservePreviewCommand(state, command, root, scratch) {
@@ -990,8 +1634,11 @@ export function reservePreviewCommand(state, command, root, scratch) {
990
1634
  where.cwd = resolve(where.cwd, words[1]);
991
1635
  continue;
992
1636
  }
1637
+ const gitWords = words[0] === 'git' ? gitArgs(words.slice(1)) : [];
1638
+ if (!gitWords)
1639
+ return deny('a git option before the command');
993
1640
  // 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.
994
- const drivePush = pushBranch(words, driveBranch);
1641
+ const drivePush = pushBranch(gitWords, driveBranch);
995
1642
  if (drivePush) {
996
1643
  if (drive && drivePush !== drive)
997
1644
  return deny('keep the test drive on one branch');
@@ -1000,8 +1647,8 @@ export function reservePreviewCommand(state, command, root, scratch) {
1000
1647
  drive = drivePush;
1001
1648
  continue;
1002
1649
  }
1003
- if (words[0] === 'git' && words[1] === 'push') {
1004
- const branch = pushBranch(words), locked = state.pr?.branch ?? create;
1650
+ if (gitWords[0] === 'push') {
1651
+ const branch = pushBranch(gitWords), locked = state.pr?.branch ?? create;
1005
1652
  if (!branch)
1006
1653
  return deny('push only a literal setup branch');
1007
1654
  if (locked && branch !== locked)
@@ -1020,10 +1667,6 @@ export function reservePreviewCommand(state, command, root, scratch) {
1020
1667
  const parsed = createArguments(words.slice(3));
1021
1668
  if (!parsed)
1022
1669
  return deny('create each pull request with a title and a body, or --fill');
1023
- const body = parsed.bodyFile;
1024
- if (body && (body.startsWith('~') || /[$*?[]/.test(body) || isSecretFile(body) || isSecretFile(realPath(resolve(where.cwd, body)))
1025
- || (outside(body, where) && !inScratch(body, where))))
1026
- return deny('use a non-secret body file inside the checkout or run scratch');
1027
1670
  if (state.requireHistory && (!parsed.head || !parsed.repository))
1028
1671
  return deny('name the repository and literal head explicitly for self-hosted previews');
1029
1672
  if (!state.repository || (parsed.repository !== undefined && parsed.repository.toLowerCase() !== state.repository.toLowerCase())
@@ -1056,6 +1699,14 @@ export function reservePreviewCommand(state, command, root, scratch) {
1056
1699
  if (!driveCreated || parsed.branch !== drive)
1057
1700
  return deny('mark ready the test drive this run opened');
1058
1701
  }
1702
+ if (words[0] === 'gh' && words[1] === 'pr' && words[2] === 'view') {
1703
+ const parsed = ghView(words.slice(3));
1704
+ if (!parsed)
1705
+ return deny("view only this run's pull requests, by number or branch name");
1706
+ // Without --repo, gh picks a repository from the checkout's remotes, which may name another one.
1707
+ if (!state.repository || parsed.repository?.toLowerCase() !== state.repository.toLowerCase())
1708
+ return deny('view pull requests of this repository only, named with --repo');
1709
+ }
1059
1710
  }
1060
1711
  // Every segment passes before any reservation becomes visible to the next tool call.
1061
1712
  if (pushes !== state.pushReservations && state.pr)
@@ -1146,8 +1797,9 @@ export function endHostSetup(log, host) {
1146
1797
  }
1147
1798
  /**
1148
1799
  * What the `vercel link`s of a command that ran left, each in the folder it ran in (the one `--cwd` names, else the
1149
- * shell's, after its `cd`), whatever the command's exit: a folder linked to a project keeps the hold; links that left
1150
- * their folders linked to none failed, and end the Vercel setup.
1800
+ * shell's, after its `cd`), whatever the command's exit: a folder with a project file of any shape keeps the hold (the
1801
+ * agent may have trimmed one that names a project); links that left their folders with none failed, and end the Vercel
1802
+ * setup.
1151
1803
  */
1152
1804
  function noteVercelLinks(log, root, command) {
1153
1805
  let cwd = root;
@@ -1164,11 +1816,64 @@ function noteVercelLinks(log, root, command) {
1164
1816
  }
1165
1817
  if (!folders.length)
1166
1818
  return;
1167
- if (folders.some((folder) => linkedVercelProject(folder) !== null))
1819
+ if (folders.some((folder) => existsSync(join(folder, '.vercel', 'project.json'))))
1168
1820
  linkedVercel.add(log);
1169
1821
  else
1170
1822
  endHostSetup(log, 'vercel');
1171
1823
  }
1824
+ /** What the run's own host commands made and linked, by the run's log: the Vercel projects `vercel project add` made, by team and name, and the projects and sites a `vercel link` or `netlify sites:create` left a folder linked to, by id. */
1825
+ const runProjects = new WeakMap();
1826
+ function hostProjects(log) {
1827
+ let projects = runProjects.get(log);
1828
+ if (!projects)
1829
+ runProjects.set(log, (projects = { added: new Set(), linked: new Map() }));
1830
+ return projects;
1831
+ }
1832
+ /** The Vercel projects a `vercel project add` of this run made, as `<team>/<name>`, the name lowercased: a name the team's project held already fails the command, so one here is the run's, in that team. */
1833
+ export const vercelProjectsAdded = (log) => hostProjects(log).added;
1834
+ /** The Vercel projects and Netlify sites a `vercel link` or `netlify sites:create` of this run left its folder linked to, by id: the ones the connection tools may connect. */
1835
+ export const linkedByRun = (log) => hostProjects(log).linked;
1836
+ /**
1837
+ * What a `vercel project add`, `vercel link` or `netlify sites:create` that ran on its own (after a `cd` at most) and
1838
+ * succeeded made: the name the add took, in the team the command named or the run had chosen, and what the link or
1839
+ * create left its folder linked to (the CLI wrote the link file last, so its id, and the team id beside it, are the
1840
+ * CLI's), the link in that team too. A link file the agent writes, a command that failed, one that runs anything after
1841
+ * the host's command, or one the shell may have skipped (after `||`, or in a pipe) proves nothing and records nothing.
1842
+ * An add that failed (its name is in use: the project is the person's) ends the Vercel setup, as a failed link does, so
1843
+ * the hold on pushes goes, unless the checkout holds a project file of any shape (the agent may have trimmed one that
1844
+ * names a project), which may build a push and holds it.
1845
+ */
1846
+ function noteHostProjects(log, root, command, failed) {
1847
+ const segments = parseCommand(command) ?? [];
1848
+ const last = segments.at(-1);
1849
+ if (!last || last.before === '||' || last.before === '|' || !segments.slice(0, -1).every((seg) => seg.words[0] === 'cd'))
1850
+ return;
1851
+ // The words as the fence reads them, the subcommand first wherever the options stand.
1852
+ const [cli, ...args] = last.words;
1853
+ const { words: [sub, ...rest], options } = cli === 'vercel' ? vercelArgs(args) : cli === 'netlify' ? netlifyArgs(args) : { words: [], options: new Map() };
1854
+ const named = VERCEL_TEAM.map((name) => options.get(name)).find((value) => typeof value === 'string');
1855
+ const team = typeof named === 'string' ? named : teamsChosen(log).get('vercel') ?? '';
1856
+ if (cli === 'vercel' && sub === 'project' && rest[0] === 'add' && rest[1]) {
1857
+ if (!failed)
1858
+ hostProjects(log).added.add(`${team}/${rest[1].toLowerCase()}`);
1859
+ else if (!existsSync(join(root, '.vercel', 'project.json')))
1860
+ endHostSetup(log, 'vercel');
1861
+ return;
1862
+ }
1863
+ if (failed)
1864
+ return;
1865
+ const cwd = segments.slice(0, -1).reduce((folder, seg) => resolve(folder, seg.words[1] ?? '.'), root);
1866
+ if (cli === 'vercel' && sub === 'link') {
1867
+ const link = linkedVercelProject(cwd);
1868
+ if (link)
1869
+ hostProjects(log).linked.set(link.projectId, { team, orgId: link.orgId });
1870
+ }
1871
+ else if (cli === 'netlify' && sub === 'sites:create') {
1872
+ const id = linkedNetlifySite(cwd);
1873
+ if (id)
1874
+ hostProjects(log).linked.set(id, { team: '' });
1875
+ }
1876
+ }
1172
1877
  /** The team the person picked for each host's CLI in this run, by the run's log. */
1173
1878
  const chosenTeams = new WeakMap();
1174
1879
  function teamsChosen(log) {
@@ -1200,15 +1905,17 @@ export const MAX_TOOL_CALLS = 400;
1200
1905
  /** What still runs past the cap, so the run can say where it got to and end. */
1201
1906
  const FINISHING = new Set(['impeccable_progress', 'impeccable_report']);
1202
1907
  /** 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. */
1203
- 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;
1908
+ const CREDENTIAL = /TOKEN|SECRET|PASSW(?:OR)?D|PASSPHRASE|CREDENTIAL|PRIVATE|(?:^|_)(?:API_?)?KEY(?:_|$)|APIKEY|AUTH(?!OR)|COOKIE|(?:^|_)PAT(?:_|$)|WEBHOOK|JWT|SESSION|(?:^|_)PASS$|SERVICE_ACCOUNT|KEYFILE|SERVICE_ROLE|_PWD$|DATABASE_URL$|^(?:AWS|AZURE|ANTHROPIC|OPENAI|OP_SESSION)_/i;
1204
1909
  /**
1205
- * The environment for the agent's shell: the person's, without credentials, whether by name or as a URL's password (a database's
1206
- * connection string), a proxy's too. A folder, a socket or AWS's region setting is a place, not a secret, whatever its name looks like, so
1910
+ * The environment for the agent's shell: the person's, without credentials, whether by name, as a URL's password (a database's
1911
+ * connection string, a proxy's too), as a service account's key, or as a value the output scan finds a secret in (GITHUB_PAT's ghp_…
1912
+ * under any name). A folder, a socket or AWS's region setting is a place, not a secret, whatever its name looks like, so
1207
1913
  * VERCEL_AUTH_CONFIG_DIR, SSH_AUTH_SOCK and AWS_REGION stay; gh and git sign in from their own config and keychain.
1208
1914
  */
1209
1915
  const PLACE = /_(?:DIR|HOME|SOCK)$|^AWS_(?:DEFAULT_)?REGION$/;
1916
+ const SECRET_VALUE = /:\/\/[^/@\s:]*:[^/@\s]+@|"private_key"/;
1210
1917
  export function shellEnvironment(env) {
1211
- return Object.fromEntries(Object.entries(env).filter(([name, value]) => (PLACE.test(name) || !CREDENTIAL.test(name)) && !/:\/\/[^/@\s:]*:[^/@\s]+@/.test(value ?? '')));
1918
+ return Object.fromEntries(Object.entries(env).filter(([name, value = '']) => (PLACE.test(name) || !CREDENTIAL.test(name)) && !SECRET_VALUE.test(value) && !scan('output', value).length));
1212
1919
  }
1213
1920
  /** The impeccable_* tools whose input is saved, sent to the web page or shown to the person. */
1214
1921
  const OUTPUT_TOOLS = new Set(['impeccable_write', 'impeccable_report', 'impeccable_progress', 'impeccable_ask']);
@@ -1224,15 +1931,14 @@ function strings(value, at = '') {
1224
1931
  }
1225
1932
  /**
1226
1933
  * Text shaped like a secret in what a tool call writes, saves or shows: the new text of a write or an edit (never what it
1227
- * replaces, and nothing in a .env file's example such as .env.example), and every string the impeccable_* tools take.
1228
- * Each string is read with its field's name in front, so a secret-like name set to a random value is seen too.
1934
+ * replaces), and every string the impeccable_* tools take. Each string is read with its field's name in front, so a secret-like
1935
+ * name set to a random value is seen too, except in a .env file's example such as .env.example, whose placeholders are just that.
1229
1936
  */
1230
1937
  function outputCheck(tool, input) {
1231
1938
  let fields;
1939
+ // Another file named as an example (config.ts.example) is checked like any file.
1940
+ const placeholders = (tool === 'write' || tool === 'edit') && basename(String(input.path ?? '')).startsWith('.env') && EXAMPLE.test(String(input.path ?? ''));
1232
1941
  if (tool === 'write' || tool === 'edit') {
1233
- // A .env file's example holds placeholders; another file named as an example (config.ts.example) is checked like any file.
1234
- if (basename(String(input.path ?? '')).startsWith('.env') && EXAMPLE.test(String(input.path ?? '')))
1235
- return null;
1236
1942
  fields = tool === 'write' ? [['content', input.content]] : [['newText', input.newText], ...strings(input.edits, 'edits').filter(([at]) => at.endsWith('.newText'))];
1237
1943
  }
1238
1944
  else {
@@ -1240,15 +1946,52 @@ function outputCheck(tool, input) {
1240
1946
  }
1241
1947
  // The one reference impeccable_write swaps for what the person typed: a test account's password.
1242
1948
  const swapped = (at, text) => tool === 'impeccable_write' && /(?:^|\/)test-accounts$/.test(String(input.path)) && /^body\.accounts\[\d+\]\.password$/.test(at) && /^secret:[0-9a-f]{12}$/.test(text);
1949
+ const read = (name, text) => scan('output', `${name}: ${text}`).find((p) => !placeholders || !PLACEHOLDER_SHAPES.has(p.name));
1243
1950
  for (const [at, text] of fields) {
1244
1951
  if (typeof text !== 'string' || swapped(at, text))
1245
1952
  continue;
1246
- const hit = scan('output', `${at.replace(/\[\d+\]/g, '').split('.').pop()}: ${text}`)[0];
1953
+ const name = at.replace(/\[\d+\]/g, '').split('.').pop();
1954
+ // plainText joins what an escape sequence splits (ghp_abc␛[0mdef…) on the way to the screen and the web page, so that is read too.
1955
+ // The text as written is read as well: taking a sequence out can also hide a secret, a random value that then runs into `.ts`.
1956
+ const plain = plainText(text);
1957
+ const hit = read(name, text) ?? (plain === text ? undefined : read(name, plain));
1247
1958
  if (hit)
1248
1959
  return found(hit, at);
1249
1960
  }
1250
1961
  return null;
1251
1962
  }
1963
+ /** The files impeccable_sync_workflow writes or removes, which no write of pi's shows the fence: the run's own once that tool answered that it wrote or removed one. */
1964
+ const WORKFLOW_FILES = ['.github/workflows/impeccable-preview.yml', '.github/workflows/pristine-preview.yml'];
1965
+ /** Whether a command runs the repository's code: a package manager's install, script or tool, which may rewrite what the scripts and tools load. */
1966
+ const runsScripts = (command) => (parseCommand(command) ?? []).some(({ words }) => {
1967
+ const cmd = words[envCount(words)] ?? '';
1968
+ return PACKAGE_MANAGERS.has(cmd) || cmd === 'npx' || cmd === 'bunx';
1969
+ });
1970
+ /** What an install writes by design, and the options next build writes into tsconfig.json: a change there says nothing about who wrote it. */
1971
+ const COMMAND_WRITES = /(?:^|\/)(?:node_modules\/|tsconfig[^/]*\.json$)/i;
1972
+ const hashOf = (path) => {
1973
+ try {
1974
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
1975
+ }
1976
+ catch {
1977
+ return '';
1978
+ }
1979
+ };
1980
+ /**
1981
+ * The files the fence trusts that a command could rewrite, each by its hash: the package.json whose scripts run, the Vercel
1982
+ * project the folder is linked to, and every file the checkout tracks or does not ignore that the scripts and tools load,
1983
+ * whatever git says of its state, since a command can commit what it rewrote. Read before a command that runs the
1984
+ * repository's code and again after it; a hash that differs is a file that command changed.
1985
+ */
1986
+ function trustedFiles(root) {
1987
+ const paths = new Set(['package.json', '.vercel/project.json']);
1988
+ // A checkout git cannot read still has the two files at the top.
1989
+ for (const path of (gitRead(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], root) ?? '').split('\0')) {
1990
+ if (path && LOADED.test(path) && !COMMAND_WRITES.test(path))
1991
+ paths.add(path);
1992
+ }
1993
+ return new Map([...paths].map((path) => [path, hashOf(join(root, path))]));
1994
+ }
1252
1995
  /** What the model reads after an output check refused a call: why, and the way a setup carries a secret instead. */
1253
1996
  const SECRET_RULE = 'A secret never goes into a file, a setting, a commit, a pull request or a message, so nothing was done: take it out. Where the setup needs one, name it as a repository secret, ${{ secrets.NAME }}, and put "Add the NAME repository secret" in the report\'s left.';
1254
1997
  /** `scans` takes every content check's match: the debug log's, and --scan-report's file when there is one. */
@@ -1258,6 +2001,18 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
1258
2001
  /** 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. */
1259
2002
  let changed;
1260
2003
  let previewChanged;
2004
+ /** The scripts and tools stop once `path`, which they load, changed, and the local preview check too unless every name of the file is a vite.config. */
2005
+ const stop = (path, names) => {
2006
+ changed ??= path;
2007
+ if (!names.every((p) => VITE_CONFIG.test(p)))
2008
+ previewChanged ??= path;
2009
+ };
2010
+ /** The paths this run wrote or named, as git names them: what a commit may carry. */
2011
+ const touched = new Set();
2012
+ /** What impeccable_sync_workflow last left at each workflow file it wrote (the sha256 of the bytes) or removed (null). */
2013
+ const synced = new Map();
2014
+ /** The trusted files as they were before each command that runs the repository's code, by its call. */
2015
+ const watched = new Map();
1261
2016
  let calls = 0;
1262
2017
  pi.on('tool_call', (event) => {
1263
2018
  if (++calls > MAX_TOOL_CALLS && !FINISHING.has(event.toolName)) {
@@ -1270,9 +2025,15 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
1270
2025
  let shown;
1271
2026
  if (event.toolName === 'bash') {
1272
2027
  shown = String(input.command ?? '');
1273
- decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log) });
2028
+ decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), touched, synced, repository: retry.repository });
1274
2029
  if (decision.verdict === 'allow')
1275
2030
  decision = reservePreviewCommand(retry, shown, root, scratch);
2031
+ if (decision.verdict === 'allow') {
2032
+ for (const path of namedPaths(shown, root))
2033
+ touched.add(path);
2034
+ if (placeOf(shown) === 'sandbox' && runsScripts(shown))
2035
+ watched.set(event.toolCallId, trustedFiles(root));
2036
+ }
1276
2037
  }
1277
2038
  else if (event.toolName === 'impeccable_verify_preview' && previewChanged) {
1278
2039
  shown = `local preview after an edit of ${previewChanged}`;
@@ -1280,15 +2041,16 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
1280
2041
  }
1281
2042
  else if (['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
1282
2043
  shown = `${event.toolName} ${String(input.path ?? '.')}`;
1283
- decision = classifyFileTool(event.toolName, input.path, root, scratch);
2044
+ decision = classifyFileTool(event.toolName, input.path, root, scratch, input.glob);
1284
2045
  if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow')
1285
2046
  decision = outputCheck(event.toolName, input) ?? decision;
1286
- // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
1287
- const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
1288
- if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow' && loaded.length) {
1289
- changed ??= String(input.path);
1290
- if (!loaded.every((p) => VITE_CONFIG.test(p)))
1291
- previewChanged ??= String(input.path);
2047
+ if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow') {
2048
+ for (const path of writtenPaths(String(input.path), root))
2049
+ touched.add(path);
2050
+ // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
2051
+ const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
2052
+ if (loaded.length)
2053
+ stop(String(input.path), loaded);
1292
2054
  }
1293
2055
  }
1294
2056
  else if (OUTPUT_TOOLS.has(event.toolName)) {
@@ -1321,13 +2083,47 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
1321
2083
  });
1322
2084
  // What the agent reads is data: text in it shaped like instructions gets a line saying so, and the result is otherwise as it was.
1323
2085
  pi.on('tool_result', (event) => {
2086
+ const text = () => event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n');
2087
+ if (event.toolName === 'impeccable_sync_workflow') {
2088
+ try {
2089
+ const answer = JSON.parse(text());
2090
+ for (const action of answer.ok === true && Array.isArray(answer.actions) ? answer.actions : []) {
2091
+ if (typeof action.path !== 'string' || !WORKFLOW_FILES.includes(action.path))
2092
+ continue;
2093
+ if (action.action === 'written' && typeof action.sha256 === 'string')
2094
+ synced.set(action.path, action.sha256);
2095
+ else if (action.action === 'removed')
2096
+ synced.set(action.path, null);
2097
+ else
2098
+ continue;
2099
+ touched.add(action.path);
2100
+ }
2101
+ }
2102
+ catch {
2103
+ // A refusal that is not JSON wrote nothing.
2104
+ }
2105
+ return undefined;
2106
+ }
1324
2107
  if (!['read', 'grep', 'bash'].includes(event.toolName))
1325
2108
  return undefined;
1326
2109
  const input = event.input;
1327
- if (event.toolName === 'bash')
2110
+ if (event.toolName === 'bash') {
1328
2111
  noteVercelLinks(log, root, String(input.command ?? ''));
2112
+ noteHostProjects(log, root, String(input.command ?? ''), event.isError);
2113
+ }
2114
+ const before = watched.get(event.toolCallId);
2115
+ if (before) {
2116
+ watched.delete(event.toolCallId);
2117
+ const after = trustedFiles(root);
2118
+ // Every file the command changed counts, a removed one too (gone from the list, its old hash stands for it): a vite.config among them leaves the local preview check to the others.
2119
+ const rewritten = [...new Set([...before.keys(), ...after.keys()])].filter((path) => before.get(path) !== after.get(path));
2120
+ if (rewritten.length)
2121
+ log.write('a command changed files the scripts and tools load', rewritten.join(', '));
2122
+ for (const path of rewritten)
2123
+ stop(path, [path]);
2124
+ }
1329
2125
  const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
1330
- const hits = scan('input', event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'), sources);
2126
+ const hits = scan('input', text(), sources);
1331
2127
  if (!hits.length)
1332
2128
  return undefined;
1333
2129
  for (const hit of hits)