@impeccable/reviews 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fence.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { readFileSync, realpathSync, statSync } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { gitEnvironment } from './sandbox.js';
5
+ import { DATA_NOTE, scan, scanRecorder } from './scan.js';
3
6
  const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
4
7
  const deny = (reason) => ({ verdict: 'deny', reason });
5
8
  const never = (reason) => ({ verdict: 'never', reason });
@@ -179,10 +182,12 @@ function pathsOf(args) {
179
182
  return args.flatMap((a) => (a.startsWith('--') && a.includes('=') ? [a.slice(a.indexOf('=') + 1)] : a.startsWith('-') ? [] : [a]));
180
183
  }
181
184
  const inside = (args, where) => !pathsOf(args).some((a) => outside(a, where));
185
+ /** Files named as examples, whose secrets are placeholders. */
186
+ const EXAMPLE = /\.(example|sample|template|dist)$/;
182
187
  /** A .env file, or a pattern that can name one, holds live secrets; the examples next to it do not. */
183
188
  export function isSecretFile(path) {
184
189
  const name = basename(path);
185
- return name.startsWith('.env') && !/\.(example|sample|template|dist)$/.test(name);
190
+ return name.startsWith('.env') && !EXAMPLE.test(name);
186
191
  }
187
192
  function rmRecursiveForce(words) {
188
193
  const at = words.indexOf('rm');
@@ -275,7 +280,7 @@ function commitReads(args) {
275
280
  }
276
281
  return values;
277
282
  }
278
- function git(args, where, protectedBranches) {
283
+ function git(args, where, protectedBranches, held = false) {
279
284
  const [sub, ...rest] = args[0] === '--no-pager' ? args.slice(1) : args;
280
285
  if (sub === 'status' || sub === 'diff' || sub === 'log' || sub === 'show' || sub === 'add') {
281
286
  const excluded = sub === 'add' && rest.includes(EXCLUDE_ENV);
@@ -304,7 +309,10 @@ function git(args, where, protectedBranches) {
304
309
  return allow();
305
310
  if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
306
311
  const branch = rest[2];
307
- return branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch) ? deny('a push to the default branch') : allow();
312
+ if (branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch))
313
+ return deny('a push to the default branch');
314
+ // A connected Vercel project builds every push, and calls a new project's first deployment Production, public at the project's own address.
315
+ return held ? deny("a push before impeccable_vercel_connect settled the linked Vercel project's first deployment: run it first") : allow();
308
316
  }
309
317
  return deny('a git command off the allowlist');
310
318
  }
@@ -347,6 +355,8 @@ function tool(args, where, scriptsChanged = false) {
347
355
  // A version or a tag may follow the name; an alias such as next@npm:other is another package, so it stays on.
348
356
  const name = (args[i] ?? '').replace(/@[\w.^~<>=*+-]+$/, '');
349
357
  const rest = args.slice(i + 1);
358
+ if (name === 'cf')
359
+ return cloudflareCli(rest);
350
360
  if (!TOOLS.has(name))
351
361
  return deny(`${name || 'that'} is not on the tool list`);
352
362
  if (name === 'playwright' && (rest[0] === 'install-deps' || rest.includes('--with-deps')))
@@ -357,6 +367,15 @@ function tool(args, where, scriptsChanged = false) {
357
367
  return deny(changedReason(scriptsChanged));
358
368
  return inside(rest, where) ? allow() : deny('a path outside the checkout');
359
369
  }
370
+ /**
371
+ * cf, Cloudflare's CLI: the tools run it in the command's own process, as the person (its sign-in, the preview
372
+ * deployments, and the service token, whose secret it prints), so none of it runs here, and a delete never does.
373
+ */
374
+ function cloudflareCli(args) {
375
+ if (args.some((a) => a === 'delete' || a === 'disconnect'))
376
+ return never('deletes on Cloudflare');
377
+ return deny("cf runs in the tools, as the person: impeccable_cloudflare_previews reads Cloudflare's previews, and impeccable_cloudflare_access signs in and makes the service token");
378
+ }
360
379
  /** A package manager's install, script or version; with `tools`, also a tool from the list through pnpm exec or yarn. */
361
380
  function packageManager(pm, args, where, scriptsChanged, tools = true) {
362
381
  const [sub = '', ...rest] = args;
@@ -410,6 +429,25 @@ function reader(cmd, args, where) {
410
429
  }
411
430
  /** A host's CLI package, by host: the one global install that runs, and only once the person said yes to setting that host up. */
412
431
  const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
432
+ /** 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
+ function asPerson(words) {
434
+ const [cmd = '', ...args] = words;
435
+ if (cmd === 'git')
436
+ return ['commit', 'push'].includes((args[0] === '--no-pager' ? args[1] : args[0]) ?? '');
437
+ return cmd === 'gh' || cmd === 'vercel' || cmd === 'netlify' || cmd === 'cf';
438
+ }
439
+ /** 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
+ export function placeOf(command) {
441
+ 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;
443
+ if (installs)
444
+ return installs === commands.length ? 'install' : null;
445
+ const person = commands.filter(asPerson).length;
446
+ if (!person)
447
+ return 'sandbox';
448
+ const either = commands.filter((words) => !asPerson(words) && (words[0] === 'cd' || words[0] === 'git')).length;
449
+ return person + either === commands.length ? 'person' : null;
450
+ }
413
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. */
414
452
  function installsHostCli(pm, args, hosts) {
415
453
  const rest = args.filter((a) => !['-g', '--global', '--location=global'].includes(a));
@@ -420,34 +458,34 @@ function installsHostCli(pm, args, hosts) {
420
458
  return global && (verbs[pm] ?? []).includes(verb) && !more.length && Object.entries(HOST_CLIS).some(([host, pkg]) => pkg === cli && hosts.has(host));
421
459
  }
422
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. */
423
- const NETLIFY_VALUES = new Set(['--name', '--context', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
424
- /** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, the one deploy context, a team's slug. */
461
+ 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. */
425
463
  const NETLIFY_VALID = {
426
464
  '--name': /^[A-Za-z0-9-]{1,63}$/,
427
465
  '--id': /^[0-9a-f-]{8,64}$/i,
428
466
  '--git-remote-url': /^(?:https:\/\/github\.com\/)?[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?(?:\.git)?$/,
429
467
  '--git-remote-name': /^[A-Za-z0-9._-]{1,50}$/,
430
- '--context': /^deploy-preview$/,
431
468
  '--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
432
469
  };
433
- /** Whether the folder is linked to a Netlify site: `.netlify/state.json` names it. */
434
- function linkedToSite(dir) {
470
+ /** The Vercel project the folder is linked to, as `vercel link` wrote it in `.vercel/project.json`; null with none. */
471
+ function linkedVercelProject(dir) {
435
472
  try {
436
- return typeof JSON.parse(readFileSync(join(dir, '.netlify', 'state.json'), 'utf8')).siteId === 'string';
473
+ const id = JSON.parse(readFileSync(join(dir, '.vercel', 'project.json'), 'utf8')).projectId;
474
+ return typeof id === 'string' ? id : null;
437
475
  }
438
476
  catch {
439
- return false;
477
+ return null;
440
478
  }
441
479
  }
442
480
  /**
443
481
  * The Netlify CLI: `--version` and `status` at any time; once the person signed in to Netlify for the setup, the
444
- * project's link and creation, in the person's own current team or the one team they chose (`team`), and a built draft
445
- * deploy for a deploy preview of a linked folder. The sign-in and the repository's connection are the tools' (a
446
- * `netlify login` outside the sign-in screen, and a `netlify init` that asks its questions in a terminal, never run).
447
- * Never production or a project's removal, and nothing of its environment variables, its API (which reaches them) or
448
- * a token on the command line.
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.
449
487
  */
450
- function netlify(args, where, agreed, team) {
488
+ function netlify(args, agreed, team) {
451
489
  const words = [];
452
490
  const options = new Map();
453
491
  for (let i = 0; i < args.length; i++) {
@@ -477,6 +515,8 @@ function netlify(args, where, agreed, team) {
477
515
  return deny('the Netlify sign-in is impeccable_ask\'s, with the id netlify-setup: it opens the browser from the sign-in screen');
478
516
  if (sub === 'init')
479
517
  return deny('netlify init asks its questions in a terminal this run does not have: impeccable_netlify_connect connects the repository');
518
+ if (sub === 'api')
519
+ return deny("netlify api prints a site's settings, its password among them: impeccable_netlify_password and impeccable_netlify_connect run it in the command's own process");
480
520
  if (!agreed)
481
521
  return deny('sets Netlify up, and the person has not signed in to Netlify for that');
482
522
  /** Only these options, each with a value of its kind. */
@@ -489,10 +529,8 @@ function netlify(args, where, agreed, team) {
489
529
  case 'sites:create':
490
530
  return !rest.length && only('--name', '--json', '--account-slug') ? allow() : deny('a netlify sites:create off the allowlist');
491
531
  case 'deploy':
492
- if (rest.length || !options.has('--build') || !options.has('--context') || !only('--build', '--context', '--json'))
493
- return deny('a netlify deploy other than a built draft for the deploy-preview context');
494
- // Where no site is linked the CLI creates one with a random name, which is not what the person said yes to.
495
- return linkedToSite(where.cwd) ? allow() : deny('a netlify deploy in a folder linked to no site');
532
+ // The build runs in the sandbox and the Netlify CLI outside it, as the person: never both at once.
533
+ return deny("netlify deploy builds the repository with the person's Netlify sign-in, and the two never run together");
496
534
  default:
497
535
  return deny(sub.startsWith('env') ? "Netlify's environment variables are the person's" : `netlify ${sub || 'without a command'} is not on the allowlist`);
498
536
  }
@@ -501,16 +539,8 @@ function netlify(args, where, agreed, team) {
501
539
  const VERCEL_VALUES = new Set(['--cwd', '--target', '--scope', '-S', '--team']);
502
540
  /** The options that name a team: the CLI never picks one without a terminal, so the run names the one the person chose. */
503
541
  const VERCEL_TEAM = ['--scope', '-S', '--team'];
504
- /**
505
- * The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
506
- * project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
507
- * the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
508
- * `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
509
- * environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
510
- * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
511
- * line.
512
- */
513
- function vercel(args, where, agreed, team) {
542
+ /** The Vercel CLI's arguments as its words, the subcommand first, and its options, each with the value it takes. */
543
+ function vercelArgs(args) {
514
544
  const words = [];
515
545
  const options = new Map();
516
546
  for (let i = 0; i < args.length; i++) {
@@ -525,6 +555,19 @@ function vercel(args, where, agreed, team) {
525
555
  const value = at > 0 ? arg.slice(at + 1) : VERCEL_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
526
556
  options.set(name, value);
527
557
  }
558
+ return { words, options };
559
+ }
560
+ /**
561
+ * 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.
568
+ */
569
+ function vercel(args, where, agreed, team, settlement = null) {
570
+ const { words, options } = vercelArgs(args);
528
571
  const [sub = '', ...rest] = words;
529
572
  if ([...options.keys()].some((name) => name === '--token' || /^-t/.test(name)))
530
573
  return deny('puts a Vercel token on the command line');
@@ -551,9 +594,17 @@ function vercel(args, where, agreed, team) {
551
594
  case 'project':
552
595
  return ['add', 'ls', 'list'].includes(rest[0] ?? '') && rest.length <= 2 && only(...VERCEL_TEAM) ? allow() : deny('a vercel project command off the allowlist');
553
596
  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');
554
600
  return rest.join(' ') === 'connect' && only(...VERCEL_TEAM) ? allow() : deny('a vercel git command other than connect');
555
- case 'deploy':
601
+ case 'deploy': {
602
+ // A deploy builds the project the folder it runs in is linked to: the one --cwd names, else this one.
603
+ const folder = resolve(where.cwd, typeof cwd === 'string' ? cwd : '.');
604
+ if (!(settlement?.how === 'project' && settlement.projectId === linkedVercelProject(folder)))
605
+ 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");
556
606
  return rest.length <= 1 && inside(rest, where) && only('--yes', '-y', '--target', ...VERCEL_TEAM) ? allow() : deny('a vercel deploy off the preview allowlist');
607
+ }
557
608
  case 'env':
558
609
  return deny("Vercel's environment variables are the person's");
559
610
  default:
@@ -611,7 +662,7 @@ function simpleCommand(seg, where, opts) {
611
662
  return deny('a cd that runs only sometimes: put it first, or after ;');
612
663
  return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
613
664
  case 'git':
614
- return git(args, where, opts.protectedBranches);
665
+ return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === linkedVercelProject(where.root)));
615
666
  case 'gh':
616
667
  if (args[0] === 'pr' && (args[1] === 'create' || args[1] === 'view'))
617
668
  return allow();
@@ -634,18 +685,105 @@ function simpleCommand(seg, where, opts) {
634
685
  case 'find':
635
686
  return reader(cmd, args, where);
636
687
  case 'netlify':
637
- return netlify(args, where, opts.hosts.has('netlify'), opts.teams.get('netlify'));
688
+ return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'));
638
689
  case 'vercel':
639
- return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'));
690
+ return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
691
+ case 'cf':
692
+ return cloudflareCli(args);
640
693
  default:
641
694
  if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
642
695
  return allow('installs the CLI of the host the person said yes to');
643
696
  return PACKAGE_MANAGERS.has(cmd) ? packageManager(cmd, args, where, opts.scriptsChanged) : deny(`${cmd} is not on the allowlist`);
644
697
  }
645
698
  }
699
+ /** 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. */
700
+ const found = (pattern, sink) => ({
701
+ verdict: 'deny',
702
+ reason: pattern.context === 'command' ? `names ${pattern.description}, which holds credentials (${pattern.name})` : `${sink} holds text shaped like ${pattern.description} (${pattern.name})`,
703
+ hit: { pattern, sink },
704
+ });
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;
709
+ try {
710
+ return readFileSync(resolve(where.cwd, path), 'utf8').slice(0, 1_000_000);
711
+ }
712
+ catch {
713
+ return null;
714
+ }
715
+ }
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 {
723
+ return null;
724
+ }
725
+ }
726
+ /** The words of a git commit or a gh pr create that are its message, title or body: text, not paths. */
727
+ function textWords(words) {
728
+ const text = new Set();
729
+ const commit = words[0] === 'git' && (words[1] === 'commit' || (words[1] === '--no-pager' && words[2] === 'commit'));
730
+ if (!commit && !(words[0] === 'gh' && words[1] === 'pr' && words[2] === 'create'))
731
+ return text;
732
+ words.forEach((w, i) => {
733
+ if (commit ? /^-[a-zA-Z]*m$|^--message$/.test(w) : /^(?:-t|--title|-b|--body)$/.test(w))
734
+ text.add(i + 1);
735
+ else if (commit ? /^-[a-zA-Z]*m.|^--message=/.test(w) : /^-[tb].|^--(?:title|body)=/.test(w))
736
+ text.add(i);
737
+ });
738
+ return text;
739
+ }
740
+ /**
741
+ * 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.
744
+ */
745
+ function contentCheck(seg, where, defaultBranch) {
746
+ const text = textWords(seg.words);
747
+ for (const [i, word] of [...seg.words, ...seg.redirects.map((r) => r.target)].entries()) {
748
+ if (text.has(i))
749
+ continue;
750
+ const path = word.startsWith('-') && word.includes('=') ? word.slice(word.indexOf('=') + 1) : word;
751
+ const hit = scan('command', word).find((p) => !p.outsideOnly || outside(path, where));
752
+ if (hit)
753
+ return found(hit, 'the command');
754
+ }
755
+ const [cmd, ...rest] = seg.words;
756
+ const args = cmd === 'git' && rest[0] === '--no-pager' ? rest.slice(1) : rest;
757
+ const texts = [];
758
+ if (cmd === 'git' && args[0] === 'commit') {
759
+ 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)]);
763
+ }
764
+ else if (cmd === 'gh' && args[0] === 'pr' && args[1] === 'create') {
765
+ const parsed = createArguments(args.slice(2));
766
+ 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
+ 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.
771
+ 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]);
775
+ }
776
+ }
777
+ for (const [sink, text] of texts) {
778
+ const hit = text ? scan('output', text)[0] : undefined;
779
+ if (hit)
780
+ return found(hit, sink);
781
+ }
782
+ return null;
783
+ }
646
784
  /** The fence's answer for one shell command run from the checkout's top folder. */
647
785
  export function classifyCommand(command, root, options = {}) {
648
- const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map() };
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 };
649
787
  const segments = parseCommand(command);
650
788
  if (!segments)
651
789
  return deny('uses shell syntax the fence does not read');
@@ -654,7 +792,9 @@ export function classifyCommand(command, root, options = {}) {
654
792
  const where = { root: realPath(root), cwd: root, scratch: options.scratch && realPath(options.scratch) };
655
793
  let result = allow();
656
794
  for (const seg of segments) {
657
- const decision = simpleCommand(seg, where, opts);
795
+ let decision = simpleCommand(seg, where, opts);
796
+ if (decision.verdict === 'allow')
797
+ decision = contentCheck(seg, where, opts.protectedBranches[0]) ?? decision;
658
798
  if (decision.verdict === 'never')
659
799
  return decision;
660
800
  if (decision.verdict === 'deny' && result.verdict === 'allow')
@@ -663,6 +803,9 @@ export function classifyCommand(command, root, options = {}) {
663
803
  if (seg.words[0] === 'cd' && decision.verdict === 'allow')
664
804
  where.cwd = resolve(where.cwd, seg.words[1]);
665
805
  }
806
+ if (result.verdict === 'allow' && placeOf(command) === null) {
807
+ return deny("joins git commit or push, gh, vercel, netlify or a host CLI's install, which run outside the sandbox as the person, to what runs in it: run them as separate commands");
808
+ }
666
809
  return result;
667
810
  }
668
811
  /**
@@ -756,14 +899,16 @@ export function classifyPreviewCommand(command, root, options = {}) {
756
899
  }
757
900
  return allow('inside the disposable preview verifier');
758
901
  }
759
- /** The fence's answer for pi's file tools: writes stay in the checkout and out of .git; reads stay in it or in the run's scratch folder, and off .env files. */
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. */
760
903
  export function classifyFileTool(tool, path, root, scratch) {
761
904
  const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
762
905
  const p = typeof path === 'string' && path ? path : '.';
763
906
  if (tool === 'write' || tool === 'edit') {
764
907
  if (outside(p, where))
765
908
  return never('writes outside the checkout');
766
- return within(realPath(resolve(root, p)), join(where.root, '.git')) ? never('writes into .git, where hooks run') : allow();
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();
767
912
  }
768
913
  if (outside(p, where) && !inScratch(p, where))
769
914
  return deny('reads outside the checkout');
@@ -830,7 +975,7 @@ function createArguments(args) {
830
975
  return;
831
976
  if (values.has('body-file') && (!bodyFile || bodyFile === '-'))
832
977
  return;
833
- return { head, repository: values.get('repo'), bodyFile };
978
+ return { head, repository: values.get('repo'), bodyFile, base: values.get('base'), filled };
834
979
  }
835
980
  /** Called synchronously only after the ordinary fence allows the entire command. */
836
981
  export function reservePreviewCommand(state, command, root, scratch) {
@@ -972,7 +1117,7 @@ export function observePreviewAttempts(state, runs, historyComplete) {
972
1117
  }
973
1118
  /** The hosts the person said yes to setting up in this run, by the run's log. */
974
1119
  const agreedHosts = new WeakMap();
975
- function hostsAgreed(log) {
1120
+ export function hostsAgreed(log) {
976
1121
  let hosts = agreedHosts.get(log);
977
1122
  if (!hosts)
978
1123
  agreedHosts.set(log, (hosts = new Set()));
@@ -988,6 +1133,42 @@ export function agreeToHostSetup(log, questionId) {
988
1133
  export function hostSetupAgreed(log, host) {
989
1134
  return hostsAgreed(log).has(host);
990
1135
  }
1136
+ /** The runs whose `vercel link` left its folder linked to a project: that project may build the next push, so its hold stands until it is settled, whatever ends the setup. */
1137
+ const linkedVercel = new WeakSet();
1138
+ /**
1139
+ * The host's setup ended before any project of this run's could build a push: the sign-in skipped or not completed, the
1140
+ * team skipped, or the link failed. Its CLI stops and the hold on pushes goes, as though the host was never offered; a
1141
+ * Vercel project linked in this run keeps its hold until it is settled.
1142
+ */
1143
+ export function endHostSetup(log, host) {
1144
+ if (host !== 'vercel' || !linkedVercel.has(log))
1145
+ hostsAgreed(log).delete(host);
1146
+ }
1147
+ /**
1148
+ * 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.
1151
+ */
1152
+ function noteVercelLinks(log, root, command) {
1153
+ let cwd = root;
1154
+ const folders = [];
1155
+ for (const { words } of parseCommand(command) ?? []) {
1156
+ if (words[0] === 'cd')
1157
+ cwd = resolve(cwd, words[1] ?? '.');
1158
+ if (words[0] !== 'vercel')
1159
+ continue;
1160
+ const { words: [sub], options } = vercelArgs(words.slice(1));
1161
+ const folder = options.get('--cwd');
1162
+ if (sub === 'link')
1163
+ folders.push(resolve(cwd, typeof folder === 'string' ? folder : '.'));
1164
+ }
1165
+ if (!folders.length)
1166
+ return;
1167
+ if (folders.some((folder) => linkedVercelProject(folder) !== null))
1168
+ linkedVercel.add(log);
1169
+ else
1170
+ endHostSetup(log, 'vercel');
1171
+ }
991
1172
  /** The team the person picked for each host's CLI in this run, by the run's log. */
992
1173
  const chosenTeams = new WeakMap();
993
1174
  function teamsChosen(log) {
@@ -1002,6 +1183,14 @@ export function chooseHostTeam(log, questionId, team) {
1002
1183
  if (host && Object.hasOwn(HOST_CLIS, host) && hostsAgreed(log).has(host) && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(team))
1003
1184
  teamsChosen(log).set(host, team);
1004
1185
  }
1186
+ /** What impeccable_vercel_connect settled of the linked Vercel project's first deployment in this run, by the run's log. */
1187
+ const settledVercel = new WeakMap();
1188
+ export function settleVercelFirstDeployment(log, settlement) {
1189
+ settledVercel.set(log, settlement);
1190
+ }
1191
+ export function vercelFirstDeploymentSettled(log) {
1192
+ return settledVercel.get(log) ?? null;
1193
+ }
1005
1194
  /** Tool calls a run makes before the fence refuses more: a setup takes far fewer, and a run steered elsewhere stops there. */
1006
1195
  export const MAX_TOOL_CALLS = 400;
1007
1196
  /** What still runs past the cap, so the run can say where it got to and end. */
@@ -1010,14 +1199,56 @@ const FINISHING = new Set(['impeccable_progress', 'impeccable_report']);
1010
1199
  const CREDENTIAL = /TOKEN|SECRET|PASSW(?:OR)?D|PASSPHRASE|CREDENTIAL|PRIVATE|(?:^|_)(?:API_?)?KEY(?:_|$)|APIKEY|AUTH(?!OR)|COOKIE|_PWD$|DATABASE_URL$|^(?:AWS|AZURE|ANTHROPIC|OPENAI|OP_SESSION)_/i;
1011
1200
  /**
1012
1201
  * The environment for the agent's shell: the person's, without credentials, whether by name or as a URL's password (a database's
1013
- * connection string), a proxy's too. A folder or a socket is a place, not a secret, so VERCEL_AUTH_CONFIG_DIR and SSH_AUTH_SOCK stay; gh
1014
- * and git sign in from their own config and keychain.
1202
+ * 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
1203
+ * VERCEL_AUTH_CONFIG_DIR, SSH_AUTH_SOCK and AWS_REGION stay; gh and git sign in from their own config and keychain.
1015
1204
  */
1205
+ const PLACE = /_(?:DIR|HOME|SOCK)$|^AWS_(?:DEFAULT_)?REGION$/;
1016
1206
  export function shellEnvironment(env) {
1017
- return Object.fromEntries(Object.entries(env).filter(([name, value]) => /_(?:DIR|HOME|SOCK)$/.test(name)
1018
- || (!CREDENTIAL.test(name) && !/:\/\/[^/@\s:]*:[^/@\s]+@/.test(value ?? ''))));
1207
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => (PLACE.test(name) || !CREDENTIAL.test(name)) && !/:\/\/[^/@\s:]*:[^/@\s]+@/.test(value ?? '')));
1208
+ }
1209
+ /** The impeccable_* tools whose input is saved, sent to the web page or shown to the person. */
1210
+ const OUTPUT_TOOLS = new Set(['impeccable_write', 'impeccable_report', 'impeccable_progress', 'impeccable_ask']);
1211
+ /** Every string in a value, by where it sits: body.env.API_TOKEN, tasks[2].title. */
1212
+ function strings(value, at = '') {
1213
+ if (typeof value === 'string')
1214
+ return [[at, value]];
1215
+ if (Array.isArray(value))
1216
+ return value.flatMap((v, i) => strings(v, `${at}[${i}]`));
1217
+ if (value && typeof value === 'object')
1218
+ return Object.entries(value).flatMap(([k, v]) => strings(v, at ? `${at}.${k}` : k));
1219
+ return [];
1220
+ }
1221
+ /**
1222
+ * 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
1223
+ * replaces, and nothing in a .env file's example such as .env.example), and every string the impeccable_* tools take.
1224
+ * Each string is read with its field's name in front, so a secret-like name set to a random value is seen too.
1225
+ */
1226
+ function outputCheck(tool, input) {
1227
+ let fields;
1228
+ if (tool === 'write' || tool === 'edit') {
1229
+ // A .env file's example holds placeholders; another file named as an example (config.ts.example) is checked like any file.
1230
+ if (basename(String(input.path ?? '')).startsWith('.env') && EXAMPLE.test(String(input.path ?? '')))
1231
+ return null;
1232
+ fields = tool === 'write' ? [['content', input.content]] : [['newText', input.newText], ...strings(input.edits, 'edits').filter(([at]) => at.endsWith('.newText'))];
1233
+ }
1234
+ else {
1235
+ fields = strings(input);
1236
+ }
1237
+ // The one reference impeccable_write swaps for what the person typed: a test account's password.
1238
+ 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);
1239
+ for (const [at, text] of fields) {
1240
+ if (typeof text !== 'string' || swapped(at, text))
1241
+ continue;
1242
+ const hit = scan('output', `${at.replace(/\[\d+\]/g, '').split('.').pop()}: ${text}`)[0];
1243
+ if (hit)
1244
+ return found(hit, at);
1245
+ }
1246
+ return null;
1019
1247
  }
1020
- export function fenceExtension(root, log, protectedBranches, scratch) {
1248
+ /** What the model reads after an output check refused a call: why, and the way a setup carries a secret instead. */
1249
+ 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.';
1250
+ /** `scans` takes every content check's match: the debug log's, and --scan-report's file when there is one. */
1251
+ export function fenceExtension(root, log, protectedBranches, scratch, scans = scanRecorder(log)) {
1021
1252
  const retry = previewRetryState(log);
1022
1253
  return (pi) => {
1023
1254
  /** 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. */
@@ -1035,7 +1266,7 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1035
1266
  let shown;
1036
1267
  if (event.toolName === 'bash') {
1037
1268
  shown = String(input.command ?? '');
1038
- decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log) });
1269
+ decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log) });
1039
1270
  if (decision.verdict === 'allow')
1040
1271
  decision = reservePreviewCommand(retry, shown, root, scratch);
1041
1272
  }
@@ -1046,6 +1277,8 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1046
1277
  else if (['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
1047
1278
  shown = `${event.toolName} ${String(input.path ?? '.')}`;
1048
1279
  decision = classifyFileTool(event.toolName, input.path, root, scratch);
1280
+ if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow')
1281
+ decision = outputCheck(event.toolName, input) ?? decision;
1049
1282
  // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
1050
1283
  const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
1051
1284
  if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow' && loaded.length) {
@@ -1054,12 +1287,27 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1054
1287
  previewChanged ??= String(input.path);
1055
1288
  }
1056
1289
  }
1290
+ else if (OUTPUT_TOOLS.has(event.toolName)) {
1291
+ const refused = outputCheck(event.toolName, input);
1292
+ if (!refused)
1293
+ return undefined;
1294
+ shown = event.toolName;
1295
+ decision = refused;
1296
+ }
1057
1297
  else {
1058
1298
  return undefined;
1059
1299
  }
1300
+ if (decision.hit) {
1301
+ scans.record({ pattern: decision.hit.pattern.name, severity: decision.hit.pattern.severity, sink: decision.hit.sink, tool: event.toolName, action: 'block' });
1302
+ // The command or the path only: never the text that matched.
1303
+ if (event.toolName === 'bash')
1304
+ shown = 'bash';
1305
+ }
1060
1306
  log.write(`fence ${decision.verdict} (${decision.reason})`, shown);
1061
1307
  if (decision.verdict === 'allow')
1062
1308
  return undefined;
1309
+ if (decision.hit?.pattern.context === 'output')
1310
+ return { block: true, reason: `Refused: ${decision.reason}. ${SECRET_RULE}` };
1063
1311
  if (decision.verdict === 'never')
1064
1312
  return { block: true, reason: `Blocked: ${decision.reason}. Impeccable never does this; find another way.` };
1065
1313
  return {
@@ -1067,5 +1315,20 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1067
1315
  reason: `Refused: ${decision.reason}. The person is never asked to run a command. Find another way with what runs (a package script, a config change, another tool on the list); if there is none, leave the step undone and put it in the report under "What was skipped, and why", with the exact command for the person.`,
1068
1316
  };
1069
1317
  });
1318
+ // 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.
1319
+ pi.on('tool_result', (event) => {
1320
+ if (!['read', 'grep', 'bash'].includes(event.toolName))
1321
+ return undefined;
1322
+ const input = event.input;
1323
+ if (event.toolName === 'bash')
1324
+ noteVercelLinks(log, root, String(input.command ?? ''));
1325
+ const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
1326
+ const hits = scan('input', event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'), sources);
1327
+ if (!hits.length)
1328
+ return undefined;
1329
+ for (const hit of hits)
1330
+ scans.record({ pattern: hit.name, severity: hit.severity, sink: 'the result', tool: event.toolName, action: 'tag' });
1331
+ return { content: [{ type: 'text', text: DATA_NOTE }, ...event.content] };
1332
+ });
1070
1333
  };
1071
1334
  }