@impeccable/reviews 0.1.0 → 0.1.1

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,7 @@
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 { DATA_NOTE, scan, scanRecorder } from './scan.js';
3
5
  const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
4
6
  const deny = (reason) => ({ verdict: 'deny', reason });
5
7
  const never = (reason) => ({ verdict: 'never', reason });
@@ -179,10 +181,12 @@ function pathsOf(args) {
179
181
  return args.flatMap((a) => (a.startsWith('--') && a.includes('=') ? [a.slice(a.indexOf('=') + 1)] : a.startsWith('-') ? [] : [a]));
180
182
  }
181
183
  const inside = (args, where) => !pathsOf(args).some((a) => outside(a, where));
184
+ /** Files named as examples, whose secrets are placeholders. */
185
+ const EXAMPLE = /\.(example|sample|template|dist)$/;
182
186
  /** A .env file, or a pattern that can name one, holds live secrets; the examples next to it do not. */
183
187
  export function isSecretFile(path) {
184
188
  const name = basename(path);
185
- return name.startsWith('.env') && !/\.(example|sample|template|dist)$/.test(name);
189
+ return name.startsWith('.env') && !EXAMPLE.test(name);
186
190
  }
187
191
  function rmRecursiveForce(words) {
188
192
  const at = words.indexOf('rm');
@@ -275,7 +279,7 @@ function commitReads(args) {
275
279
  }
276
280
  return values;
277
281
  }
278
- function git(args, where, protectedBranches) {
282
+ function git(args, where, protectedBranches, held = false) {
279
283
  const [sub, ...rest] = args[0] === '--no-pager' ? args.slice(1) : args;
280
284
  if (sub === 'status' || sub === 'diff' || sub === 'log' || sub === 'show' || sub === 'add') {
281
285
  const excluded = sub === 'add' && rest.includes(EXCLUDE_ENV);
@@ -304,7 +308,10 @@ function git(args, where, protectedBranches) {
304
308
  return allow();
305
309
  if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
306
310
  const branch = rest[2];
307
- return branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch) ? deny('a push to the default branch') : allow();
311
+ if (branch.includes(':') || branch === 'HEAD' || protectedBranches.includes(branch))
312
+ return deny('a push to the default branch');
313
+ // A connected Vercel project builds every push, and calls a new project's first deployment Production, public at the project's own address.
314
+ return held ? deny("a push before impeccable_vercel_connect settled the linked Vercel project's first deployment: run it first") : allow();
308
315
  }
309
316
  return deny('a git command off the allowlist');
310
317
  }
@@ -430,6 +437,16 @@ const NETLIFY_VALID = {
430
437
  '--context': /^deploy-preview$/,
431
438
  '--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
432
439
  };
440
+ /** The Vercel project the folder is linked to, as `vercel link` wrote it in `.vercel/project.json`; null with none. */
441
+ function linkedVercelProject(dir) {
442
+ try {
443
+ const id = JSON.parse(readFileSync(join(dir, '.vercel', 'project.json'), 'utf8')).projectId;
444
+ return typeof id === 'string' ? id : null;
445
+ }
446
+ catch {
447
+ return null;
448
+ }
449
+ }
433
450
  /** Whether the folder is linked to a Netlify site: `.netlify/state.json` names it. */
434
451
  function linkedToSite(dir) {
435
452
  try {
@@ -444,8 +461,9 @@ function linkedToSite(dir) {
444
461
  * project's link and creation, in the person's own current team or the one team they chose (`team`), and a built draft
445
462
  * deploy for a deploy preview of a linked folder. The sign-in and the repository's connection are the tools' (a
446
463
  * `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.
464
+ * Never production or a project's removal, and nothing of its environment variables, its API (which reaches them, and
465
+ * prints a site's password: impeccable_netlify_password runs it in the command's own process) or a token on the
466
+ * command line.
449
467
  */
450
468
  function netlify(args, where, agreed, team) {
451
469
  const words = [];
@@ -477,6 +495,8 @@ function netlify(args, where, agreed, team) {
477
495
  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
496
  if (sub === 'init')
479
497
  return deny('netlify init asks its questions in a terminal this run does not have: impeccable_netlify_connect connects the repository');
498
+ if (sub === 'api')
499
+ 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
500
  if (!agreed)
481
501
  return deny('sets Netlify up, and the person has not signed in to Netlify for that');
482
502
  /** Only these options, each with a value of its kind. */
@@ -510,7 +530,7 @@ const VERCEL_TEAM = ['--scope', '-S', '--team'];
510
530
  * command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
511
531
  * line.
512
532
  */
513
- function vercel(args, where, agreed, team) {
533
+ function vercel(args, where, agreed, team, settlement = null) {
514
534
  const words = [];
515
535
  const options = new Map();
516
536
  for (let i = 0; i < args.length; i++) {
@@ -551,9 +571,17 @@ function vercel(args, where, agreed, team) {
551
571
  case 'project':
552
572
  return ['add', 'ls', 'list'].includes(rest[0] ?? '') && rest.length <= 2 && only(...VERCEL_TEAM) ? allow() : deny('a vercel project command off the allowlist');
553
573
  case 'git':
574
+ // 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.
575
+ if (settlement?.how === 'connection')
576
+ 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
577
  return rest.join(' ') === 'connect' && only(...VERCEL_TEAM) ? allow() : deny('a vercel git command other than connect');
555
- case 'deploy':
578
+ case 'deploy': {
579
+ // A deploy builds the project the folder it runs in is linked to: the one --cwd names, else this one.
580
+ const folder = resolve(where.cwd, typeof cwd === 'string' ? cwd : '.');
581
+ if (!(settlement?.how === 'project' && settlement.projectId === linkedVercelProject(folder)))
582
+ 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
583
  return rest.length <= 1 && inside(rest, where) && only('--yes', '-y', '--target', ...VERCEL_TEAM) ? allow() : deny('a vercel deploy off the preview allowlist');
584
+ }
557
585
  case 'env':
558
586
  return deny("Vercel's environment variables are the person's");
559
587
  default:
@@ -611,7 +639,7 @@ function simpleCommand(seg, where, opts) {
611
639
  return deny('a cd that runs only sometimes: put it first, or after ;');
612
640
  return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
613
641
  case 'git':
614
- return git(args, where, opts.protectedBranches);
642
+ return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === linkedVercelProject(where.root)));
615
643
  case 'gh':
616
644
  if (args[0] === 'pr' && (args[1] === 'create' || args[1] === 'view'))
617
645
  return allow();
@@ -636,16 +664,101 @@ function simpleCommand(seg, where, opts) {
636
664
  case 'netlify':
637
665
  return netlify(args, where, opts.hosts.has('netlify'), opts.teams.get('netlify'));
638
666
  case 'vercel':
639
- return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'));
667
+ return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
640
668
  default:
641
669
  if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
642
670
  return allow('installs the CLI of the host the person said yes to');
643
671
  return PACKAGE_MANAGERS.has(cmd) ? packageManager(cmd, args, where, opts.scriptsChanged) : deny(`${cmd} is not on the allowlist`);
644
672
  }
645
673
  }
674
+ /** 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. */
675
+ const found = (pattern, sink) => ({
676
+ verdict: 'deny',
677
+ reason: pattern.context === 'command' ? `names ${pattern.description}, which holds credentials (${pattern.name})` : `${sink} holds text shaped like ${pattern.description} (${pattern.name})`,
678
+ hit: { pattern, sink },
679
+ });
680
+ /** 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. */
681
+ function readable(path, where) {
682
+ if (path.startsWith('~') || /[$*?[]/.test(path) || isSecretFile(path) || (outside(path, where) && !inScratch(path, where)))
683
+ return null;
684
+ try {
685
+ return readFileSync(resolve(where.cwd, path), 'utf8').slice(0, 1_000_000);
686
+ }
687
+ catch {
688
+ return null;
689
+ }
690
+ }
691
+ /** The messages of the commits `revisions` name, as git log gives them; null when git cannot read them. */
692
+ function commitMessages(revisions, cwd) {
693
+ try {
694
+ // The closing -- reads each as a commit even where a file of the same name is there.
695
+ return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
696
+ }
697
+ catch {
698
+ return null;
699
+ }
700
+ }
701
+ /** The words of a git commit or a gh pr create that are its message, title or body: text, not paths. */
702
+ function textWords(words) {
703
+ const text = new Set();
704
+ const commit = words[0] === 'git' && (words[1] === 'commit' || (words[1] === '--no-pager' && words[2] === 'commit'));
705
+ if (!commit && !(words[0] === 'gh' && words[1] === 'pr' && words[2] === 'create'))
706
+ return text;
707
+ words.forEach((w, i) => {
708
+ if (commit ? /^-[a-zA-Z]*m$|^--message$/.test(w) : /^(?:-t|--title|-b|--body)$/.test(w))
709
+ text.add(i + 1);
710
+ else if (commit ? /^-[a-zA-Z]*m.|^--message=/.test(w) : /^-[tb].|^--(?:title|body)=/.test(w))
711
+ text.add(i);
712
+ });
713
+ return text;
714
+ }
715
+ /**
716
+ * The content checks on a command the fence allows: a file that holds credentials, named anywhere in it but its message; and
717
+ * 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
718
+ * a pull request's title, body, body file, or the commit messages --fill takes them from.
719
+ */
720
+ function contentCheck(seg, where, defaultBranch) {
721
+ const text = textWords(seg.words);
722
+ for (const [i, word] of [...seg.words, ...seg.redirects.map((r) => r.target)].entries()) {
723
+ if (text.has(i))
724
+ continue;
725
+ const path = word.startsWith('-') && word.includes('=') ? word.slice(word.indexOf('=') + 1) : word;
726
+ const hit = scan('command', word).find((p) => !p.outsideOnly || outside(path, where));
727
+ if (hit)
728
+ return found(hit, 'the command');
729
+ }
730
+ const [cmd, ...rest] = seg.words;
731
+ const args = cmd === 'git' && rest[0] === '--no-pager' ? rest.slice(1) : rest;
732
+ const texts = [];
733
+ if (cmd === 'git' && args[0] === 'commit') {
734
+ texts.push(['the commit message', args.slice(1).join('\n')]);
735
+ // -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.
736
+ for (const v of commitReads(args.slice(1)))
737
+ texts.push(['the commit message file', readable(v, where)], ['the commit message it reuses', commitMessages(['-1', '--end-of-options', v], where.cwd)]);
738
+ }
739
+ else if (cmd === 'gh' && args[0] === 'pr' && args[1] === 'create') {
740
+ const parsed = createArguments(args.slice(2));
741
+ texts.push(["the pull request's title or body", args.slice(2).join('\n')]);
742
+ if (parsed?.bodyFile)
743
+ texts.push(["the pull request's body file", readable(parsed.bodyFile, where)]);
744
+ if (parsed?.filled) {
745
+ // 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.
746
+ const base = parsed.base ?? defaultBranch, head = parsed.head ?? 'HEAD';
747
+ const ranges = [...(base ? [`origin/${base}..${head}`, `${base}..${head}`].map((range) => ['--end-of-options', range]) : []), ['-n', '100', '--end-of-options', head]];
748
+ const messages = ranges.reduce((read, range) => read ?? commitMessages(range, where.cwd), null);
749
+ texts.push(["the commit messages --fill takes the pull request's title and body from", messages]);
750
+ }
751
+ }
752
+ for (const [sink, text] of texts) {
753
+ const hit = text ? scan('output', text)[0] : undefined;
754
+ if (hit)
755
+ return found(hit, sink);
756
+ }
757
+ return null;
758
+ }
646
759
  /** The fence's answer for one shell command run from the checkout's top folder. */
647
760
  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() };
761
+ 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
762
  const segments = parseCommand(command);
650
763
  if (!segments)
651
764
  return deny('uses shell syntax the fence does not read');
@@ -654,7 +767,9 @@ export function classifyCommand(command, root, options = {}) {
654
767
  const where = { root: realPath(root), cwd: root, scratch: options.scratch && realPath(options.scratch) };
655
768
  let result = allow();
656
769
  for (const seg of segments) {
657
- const decision = simpleCommand(seg, where, opts);
770
+ let decision = simpleCommand(seg, where, opts);
771
+ if (decision.verdict === 'allow')
772
+ decision = contentCheck(seg, where, opts.protectedBranches[0]) ?? decision;
658
773
  if (decision.verdict === 'never')
659
774
  return decision;
660
775
  if (decision.verdict === 'deny' && result.verdict === 'allow')
@@ -830,7 +945,7 @@ function createArguments(args) {
830
945
  return;
831
946
  if (values.has('body-file') && (!bodyFile || bodyFile === '-'))
832
947
  return;
833
- return { head, repository: values.get('repo'), bodyFile };
948
+ return { head, repository: values.get('repo'), bodyFile, base: values.get('base'), filled };
834
949
  }
835
950
  /** Called synchronously only after the ordinary fence allows the entire command. */
836
951
  export function reservePreviewCommand(state, command, root, scratch) {
@@ -1002,6 +1117,14 @@ export function chooseHostTeam(log, questionId, team) {
1002
1117
  if (host && Object.hasOwn(HOST_CLIS, host) && hostsAgreed(log).has(host) && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(team))
1003
1118
  teamsChosen(log).set(host, team);
1004
1119
  }
1120
+ /** What impeccable_vercel_connect settled of the linked Vercel project's first deployment in this run, by the run's log. */
1121
+ const settledVercel = new WeakMap();
1122
+ export function settleVercelFirstDeployment(log, settlement) {
1123
+ settledVercel.set(log, settlement);
1124
+ }
1125
+ export function vercelFirstDeploymentSettled(log) {
1126
+ return settledVercel.get(log) ?? null;
1127
+ }
1005
1128
  /** Tool calls a run makes before the fence refuses more: a setup takes far fewer, and a run steered elsewhere stops there. */
1006
1129
  export const MAX_TOOL_CALLS = 400;
1007
1130
  /** What still runs past the cap, so the run can say where it got to and end. */
@@ -1010,14 +1133,56 @@ const FINISHING = new Set(['impeccable_progress', 'impeccable_report']);
1010
1133
  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
1134
  /**
1012
1135
  * 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.
1136
+ * 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
1137
+ * VERCEL_AUTH_CONFIG_DIR, SSH_AUTH_SOCK and AWS_REGION stay; gh and git sign in from their own config and keychain.
1015
1138
  */
1139
+ const PLACE = /_(?:DIR|HOME|SOCK)$|^AWS_(?:DEFAULT_)?REGION$/;
1016
1140
  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 ?? ''))));
1141
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => (PLACE.test(name) || !CREDENTIAL.test(name)) && !/:\/\/[^/@\s:]*:[^/@\s]+@/.test(value ?? '')));
1142
+ }
1143
+ /** The impeccable_* tools whose input is saved, sent to the web page or shown to the person. */
1144
+ const OUTPUT_TOOLS = new Set(['impeccable_write', 'impeccable_report', 'impeccable_progress', 'impeccable_ask']);
1145
+ /** Every string in a value, by where it sits: body.env.API_TOKEN, tasks[2].title. */
1146
+ function strings(value, at = '') {
1147
+ if (typeof value === 'string')
1148
+ return [[at, value]];
1149
+ if (Array.isArray(value))
1150
+ return value.flatMap((v, i) => strings(v, `${at}[${i}]`));
1151
+ if (value && typeof value === 'object')
1152
+ return Object.entries(value).flatMap(([k, v]) => strings(v, at ? `${at}.${k}` : k));
1153
+ return [];
1019
1154
  }
1020
- export function fenceExtension(root, log, protectedBranches, scratch) {
1155
+ /**
1156
+ * 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
1157
+ * replaces, and nothing in a .env file's example such as .env.example), and every string the impeccable_* tools take.
1158
+ * Each string is read with its field's name in front, so a secret-like name set to a random value is seen too.
1159
+ */
1160
+ function outputCheck(tool, input) {
1161
+ let fields;
1162
+ if (tool === 'write' || tool === 'edit') {
1163
+ // A .env file's example holds placeholders; another file named as an example (config.ts.example) is checked like any file.
1164
+ if (basename(String(input.path ?? '')).startsWith('.env') && EXAMPLE.test(String(input.path ?? '')))
1165
+ return null;
1166
+ fields = tool === 'write' ? [['content', input.content]] : [['newText', input.newText], ...strings(input.edits, 'edits').filter(([at]) => at.endsWith('.newText'))];
1167
+ }
1168
+ else {
1169
+ fields = strings(input);
1170
+ }
1171
+ // The one reference impeccable_write swaps for what the person typed: a test account's password.
1172
+ 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);
1173
+ for (const [at, text] of fields) {
1174
+ if (typeof text !== 'string' || swapped(at, text))
1175
+ continue;
1176
+ const hit = scan('output', `${at.replace(/\[\d+\]/g, '').split('.').pop()}: ${text}`)[0];
1177
+ if (hit)
1178
+ return found(hit, at);
1179
+ }
1180
+ return null;
1181
+ }
1182
+ /** What the model reads after an output check refused a call: why, and the way a setup carries a secret instead. */
1183
+ 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.';
1184
+ /** `scans` takes every content check's match: the debug log's, and --scan-report's file when there is one. */
1185
+ export function fenceExtension(root, log, protectedBranches, scratch, scans = scanRecorder(log)) {
1021
1186
  const retry = previewRetryState(log);
1022
1187
  return (pi) => {
1023
1188
  /** 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 +1200,7 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1035
1200
  let shown;
1036
1201
  if (event.toolName === 'bash') {
1037
1202
  shown = String(input.command ?? '');
1038
- decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log) });
1203
+ decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log) });
1039
1204
  if (decision.verdict === 'allow')
1040
1205
  decision = reservePreviewCommand(retry, shown, root, scratch);
1041
1206
  }
@@ -1046,6 +1211,8 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1046
1211
  else if (['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
1047
1212
  shown = `${event.toolName} ${String(input.path ?? '.')}`;
1048
1213
  decision = classifyFileTool(event.toolName, input.path, root, scratch);
1214
+ if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow')
1215
+ decision = outputCheck(event.toolName, input) ?? decision;
1049
1216
  // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
1050
1217
  const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
1051
1218
  if ((event.toolName === 'write' || event.toolName === 'edit') && decision.verdict === 'allow' && loaded.length) {
@@ -1054,12 +1221,27 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1054
1221
  previewChanged ??= String(input.path);
1055
1222
  }
1056
1223
  }
1224
+ else if (OUTPUT_TOOLS.has(event.toolName)) {
1225
+ const refused = outputCheck(event.toolName, input);
1226
+ if (!refused)
1227
+ return undefined;
1228
+ shown = event.toolName;
1229
+ decision = refused;
1230
+ }
1057
1231
  else {
1058
1232
  return undefined;
1059
1233
  }
1234
+ if (decision.hit) {
1235
+ scans.record({ pattern: decision.hit.pattern.name, severity: decision.hit.pattern.severity, sink: decision.hit.sink, tool: event.toolName, action: 'block' });
1236
+ // The command or the path only: never the text that matched.
1237
+ if (event.toolName === 'bash')
1238
+ shown = 'bash';
1239
+ }
1060
1240
  log.write(`fence ${decision.verdict} (${decision.reason})`, shown);
1061
1241
  if (decision.verdict === 'allow')
1062
1242
  return undefined;
1243
+ if (decision.hit?.pattern.context === 'output')
1244
+ return { block: true, reason: `Refused: ${decision.reason}. ${SECRET_RULE}` };
1063
1245
  if (decision.verdict === 'never')
1064
1246
  return { block: true, reason: `Blocked: ${decision.reason}. Impeccable never does this; find another way.` };
1065
1247
  return {
@@ -1067,5 +1249,18 @@ export function fenceExtension(root, log, protectedBranches, scratch) {
1067
1249
  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
1250
  };
1069
1251
  });
1252
+ // 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.
1253
+ pi.on('tool_result', (event) => {
1254
+ if (!['read', 'grep', 'bash'].includes(event.toolName))
1255
+ return undefined;
1256
+ const input = event.input;
1257
+ const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
1258
+ const hits = scan('input', event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'), sources);
1259
+ if (!hits.length)
1260
+ return undefined;
1261
+ for (const hit of hits)
1262
+ scans.record({ pattern: hit.name, severity: hit.severity, sink: 'the result', tool: event.toolName, action: 'tag' });
1263
+ return { content: [{ type: 'text', text: DATA_NOTE }, ...event.content] };
1264
+ });
1070
1265
  };
1071
1266
  }
package/dist/focus.js CHANGED
@@ -6,54 +6,82 @@
6
6
  */
7
7
  /** The state items and checkup checks this setup works on. */
8
8
  const ITEMS = new Set(['scan', 'preview', 'signin']);
9
- /** The one lock in front of the previews this setup gets past, Vercel's, as the state's item, card and check name it. Where the lock is Vercel's they are part of the setup; for any other (Cloudflare Access) they are left alone. */
10
- const LOCK = { item: 'preview-protection', card: 'protection', host: 'Vercel' };
9
+ /** The locks in front of the previews this setup gets past, Vercel's login, Netlify's password and Cloudflare Access, by the host the state's item, card and check name. Where the lock is one of theirs they are part of the setup; for any other they are left alone. */
10
+ const LOCK = { item: 'preview-protection', card: 'protection', hosts: ['Vercel', 'Netlify', 'Cloudflare Access'] };
11
+ /** The apps question, as the state's item and card and the write name it: part of the setup only on a repository whose scan proposed several apps, which is where the state has the item (the monorepo case). */
12
+ const APPS = { item: 'apps', card: 'apps', write: 'apps' };
11
13
  /** The setup page's cards this setup covers, by their key in the state's `steps`: every other card is not part of it. */
12
14
  const CARDS = new Set(['installed', 'preview', 'signin', 'done']);
13
15
  /** The writes this setup makes, by their key in the instructions' `endpoints` and `writes`; `previewUrlPattern` saves the address of a host's previews that the host does not report to GitHub. */
14
16
  const WRITES = new Set(['preview', 'testAccounts', 'connectionCheck', 'latestPullCheck', 'previewUrlPattern']);
15
17
  /** What the model reads of the instructions; `steps`, `rules`, `endpoints` and `writes` are trimmed further. */
16
18
  const INSTRUCTIONS = ['product', 'goal', 'signIn', 'buildSettings', 'endpoints', 'writes', 'steps', 'rules'];
17
- /** A step or rule about a card this setup leaves alone: several apps, the design contract or profile, personas, review modules and settings, exclusions, preview protection (which the case reads from the state's item), preview credentials, the address pattern. */
18
- const OTHER_CARDS = /\bapps\b|\bprofile\b|\bcontract\b|persona|\bmodules?\b|exclu|protect|bypass|Cloudflare Access|review.settings|reviewSettings|preview-auth|previewAuth|pattern/i;
19
+ /** The words of a step or rule about a card this setup leaves alone: the design contract or profile, personas, review modules and settings, exclusions, preview protection (which the case reads from the state's item), preview credentials, the address pattern; and several apps, except in the monorepo case. */
20
+ const OTHER_CARD_WORDS = [String.raw `\bprofile\b`, String.raw `\bcontract\b`, 'persona', String.raw `\bmodules?\b`, 'exclu', 'protect', 'bypass', 'Cloudflare Access', 'review.settings', 'reviewSettings', 'preview-auth', 'previewAuth', 'pattern'];
21
+ const otherCards = (apps) => new RegExp([...(apps ? [] : [String.raw `\bapps\b`]), ...OTHER_CARD_WORDS].join('|'), 'i');
19
22
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
20
23
  const only = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
21
24
  const without = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
22
- /** GET /api/setup/instructions, trimmed to this setup. */
23
- export function focusInstructions(instructions) {
25
+ const withApps = (keys, key, apps) => (apps ? new Set([...keys, key]) : keys);
26
+ const records = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
27
+ const reposOf = (answer) => records(record(answer)?.repos);
28
+ /** A repository of the state or the checkup whose scan proposed several apps: it has the apps item, or the apps check, to answer or answered. */
29
+ const hasApps = (repo) => Boolean(record(repo.items)?.[APPS.item]) || records(repo.checks).some((check) => check.key === APPS.item);
30
+ /** Whether a repository in the state or the checkup has several apps. */
31
+ export function severalApps(answer) {
32
+ return reposOf(answer).some(hasApps);
33
+ }
34
+ /**
35
+ * Whether the state or the checkup shows a card of this setup `not-selected` on `repo`, a repository with several apps.
36
+ * The command's token covers the apps there were when the person signed in, so this is an app the scan proposed later.
37
+ */
38
+ export function appsLeftOut(answer, repo) {
39
+ const out = (status) => status === 'not-selected';
40
+ return reposOf(answer).some((r) => String(r.name).toLowerCase() === repo.toLowerCase() && hasApps(r)
41
+ && (Object.entries(record(r.items) ?? {}).some(([key, item]) => ITEMS.has(key) && (out(record(item)?.status) || records(record(item)?.perApp).some((app) => out(app.status))))
42
+ || records(r.checks).some((check) => ITEMS.has(String(check.key)) && out(check.state))));
43
+ }
44
+ /** GET /api/setup/instructions, trimmed to this setup; with `apps`, for a state with several apps, the apps write and the lines about several apps stay. */
45
+ export function focusInstructions(instructions, { apps = false } = {}) {
24
46
  const all = record(instructions);
25
47
  if (!all)
26
48
  return instructions;
49
+ const writes = withApps(WRITES, APPS.write, apps);
50
+ const other = otherCards(apps);
27
51
  const trim = (key, value) => {
28
52
  if (key === 'endpoints' || key === 'writes')
29
- return record(value) ? only(record(value), WRITES) : value;
53
+ return record(value) ? only(record(value), writes) : value;
30
54
  if (key === 'steps' || key === 'rules')
31
- return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !OTHER_CARDS.test(line)) : value;
55
+ return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !other.test(line)) : value;
32
56
  return value;
33
57
  };
34
58
  return Object.fromEntries(INSTRUCTIONS.filter((key) => key in all).map((key) => [key, trim(key, all[key])]));
35
59
  }
36
- /** Whether a state item is the lock this setup gets past. */
37
- const vercelLock = (item) => record(item)?.host === LOCK.host;
38
- /** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, the lock in front of its previews when it is Vercel's, and the titles of the cards left alone. */
60
+ /** Whether a state item is a lock this setup gets past. */
61
+ const hostLock = (item) => LOCK.hosts.includes(String(record(item)?.host));
62
+ /** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, its apps item where the scan proposed several apps, the lock in front of its previews when it is one this setup gets past, and the titles of the cards left alone. */
39
63
  export function focusState(state) {
40
64
  const all = record(state);
41
65
  if (!all)
42
66
  return state;
43
67
  const steps = Array.isArray(all.steps) ? all.steps.map(record) : [];
44
- // The lock's card is part of the setup only when every lock the scan read is Vercel's: another host's keeps it among the cards left alone.
68
+ // The lock's card is part of the setup only when every lock the scan read is one this setup gets past: another host's keeps it among the cards left alone.
45
69
  const locks = Array.isArray(all.repos) ? all.repos.map((r) => record(record(r)?.items)?.[LOCK.item]).filter(Boolean) : [];
46
- const vercelOnly = locks.length > 0 && locks.every(vercelLock);
47
- const outside = steps.filter((step) => step && !CARDS.has(String(step.key)) && !(vercelOnly && step.key === LOCK.card) && typeof step.title === 'string').map((step) => step.title);
70
+ const hostsOnly = locks.length > 0 && locks.every(hostLock);
71
+ const apps = severalApps(all);
72
+ const cards = withApps(CARDS, APPS.card, apps);
73
+ const items = withApps(ITEMS, APPS.item, apps);
74
+ const writes = withApps(WRITES, APPS.write, apps);
75
+ const outside = steps.filter((step) => step && !cards.has(String(step.key)) && !(hostsOnly && step.key === LOCK.card) && typeof step.title === 'string').map((step) => step.title);
48
76
  const repo = (value) => {
49
77
  const r = record(value);
50
78
  if (!r)
51
79
  return value;
52
- const items = record(r.items);
80
+ const own = record(r.items);
53
81
  return {
54
82
  ...r,
55
- ...(items ? { items: Object.fromEntries(Object.entries(items).filter(([key, item]) => ITEMS.has(key) || (key === LOCK.item && vercelLock(item)))) } : {}),
56
- ...(record(r.endpoints) ? { endpoints: only(record(r.endpoints), WRITES) } : {}),
83
+ ...(own ? { items: Object.fromEntries(Object.entries(own).filter(([key, item]) => items.has(key) || (key === LOCK.item && hostLock(item)))) } : {}),
84
+ ...(record(r.endpoints) ? { endpoints: only(record(r.endpoints), writes) } : {}),
57
85
  };
58
86
  };
59
87
  return {
@@ -64,9 +92,9 @@ export function focusState(state) {
64
92
  }
65
93
  /**
66
94
  * GET /api/setup/checkup, trimmed the same way: each repository's checks of the
67
- * scan, the preview and the sign-in, the lists with only those checks' entries,
68
- * and the status those lists make. Its summary and advice counted every card, so
69
- * they go.
95
+ * scan, the preview, the sign-in and the apps (a check only a repository with
96
+ * several apps has), the lists with only those checks' entries, and the status
97
+ * those lists make. Its summary and advice counted every card, so they go.
70
98
  */
71
99
  export function focusCheckup(checkup) {
72
100
  const all = record(checkup);
@@ -80,7 +108,7 @@ export function focusCheckup(checkup) {
80
108
  if (!r || !Array.isArray(r.checks))
81
109
  return value;
82
110
  // The lock's check names its host in what it says while it asks for a key; once the key is saved it says nothing of the host, and the lists no longer carry it.
83
- const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || (record(c)?.key === LOCK.item && new RegExp(`\\b${LOCK.host}\\b`).test(String(record(c)?.says))));
111
+ const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || record(c)?.key === APPS.item || (record(c)?.key === LOCK.item && new RegExp(`\\b(${LOCK.hosts.join('|')})\\b`).test(String(record(c)?.says))));
84
112
  for (const says of checks.map((c) => record(c)?.says))
85
113
  if (typeof r.name === 'string' && typeof says === 'string' && says)
86
114
  kept.push({ repo: r.name, says });
@@ -19,7 +19,9 @@ export const HOST_QUESTIONS = {
19
19
  },
20
20
  'netlify-team': { kind: 'single', title: 'Netlify team', prompt: 'Which Netlify team should own the new site?' },
21
21
  };
22
- /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, and the consent to a bypass secret for Vercel's login, with the paste when the CLI cannot make one. */
22
+ /** Where a person reads or changes a site's password in Netlify, as Netlify's docs name the page on every plan. */
23
+ export const NETLIFY_PASSWORD_WHERE = 'Project configuration → General → Visitor access';
24
+ /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one. */
23
25
  export const HOST_SCREENS = {
24
26
  'netlify-signin-retry': { id: 'netlify-signin-retry', kind: 'confirm', title: 'Sign in to Netlify', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
25
27
  'netlify-access': {
@@ -61,6 +63,49 @@ export const HOST_SCREENS = {
61
63
  prompt: 'Vercel keeps your previews behind a login, and the Vercel CLI here could not make a bypass secret for them: that takes your Vercel sign-in, this folder linked to the project, and the Member role. Make one yourself: your project → Settings → Deployment Protection → Protection Bypass for Automation → Create, named "Impeccable reviews". It is stored encrypted and used only to open previews.',
62
64
  hint: 'Paste the secret',
63
65
  },
66
+ // Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person, and the paste when it cannot read it.
67
+ 'netlify-password': {
68
+ id: 'netlify-password',
69
+ kind: 'confirm',
70
+ title: 'Your Netlify previews are protected',
71
+ hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password. The run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
72
+ prompt: 'Store the site password for Impeccable?',
73
+ confirmLabel: 'Store',
74
+ skipLabel: 'Skip',
75
+ },
76
+ 'netlify-password-key': {
77
+ id: 'netlify-password-key',
78
+ kind: 'text',
79
+ sensitive: true,
80
+ title: 'Your Netlify previews are protected',
81
+ prompt: `Netlify keeps your previews behind the site password, and the run could not read it: the Netlify CLI here is signed out, the site is in none of your teams, or Netlify does not give the password back. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
82
+ hint: 'Paste the password',
83
+ },
84
+ // Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person, the API token that takes (wrangler's sign-in cannot reach Access), and the paste where the run does not create one.
85
+ 'cloudflare-access': {
86
+ id: 'cloudflare-access',
87
+ kind: 'confirm',
88
+ title: 'Your Cloudflare previews are protected',
89
+ hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
90
+ prompt: 'Create the token for Impeccable?',
91
+ confirmLabel: 'Create',
92
+ skipLabel: 'Skip',
93
+ },
94
+ 'cloudflare-access-api': {
95
+ id: 'cloudflare-access-api',
96
+ kind: 'text',
97
+ sensitive: true,
98
+ title: 'One Cloudflare API token',
99
+ prompt: 'Creating the token takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
100
+ hint: 'Paste the API token',
101
+ },
102
+ 'cloudflare-access-key': {
103
+ id: 'cloudflare-access-key',
104
+ kind: 'account',
105
+ fields: ['Client ID', 'Client secret'],
106
+ title: 'Your Cloudflare previews are protected',
107
+ prompt: 'Cloudflare Access keeps your previews behind a login. Paste a service token for Impeccable, or make one: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, named "Impeccable reviews", then allow it in your Access application with a Service Auth policy. It is stored encrypted and used only to open previews.',
108
+ },
64
109
  };
65
110
  /** The ids whose screens the tool puts up itself, when it needs the person, and takes down: the web page follows those, not the model's call. */
66
111
  export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);