@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/LICENSE +14 -0
- package/README.md +29 -8
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +321 -0
- package/dist/cloudflare.js +291 -0
- package/dist/fence.js +309 -46
- package/dist/focus.js +49 -21
- package/dist/host-questions.js +47 -1
- package/dist/hosted.js +95 -16
- package/dist/log.js +6 -1
- package/dist/login.js +9 -7
- package/dist/main.js +86 -27
- package/dist/netlify-password.js +89 -0
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +17 -0
- package/dist/playbook/cases/monorepo.md +22 -0
- package/dist/playbook/cases/netlify-previews.md +1 -1
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +13 -11
- package/dist/playbook/cases/vercel-setup.md +7 -5
- package/dist/playbook.js +4 -3
- package/dist/preflight.js +56 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/scan.js +111 -0
- package/dist/tools.js +337 -63
- package/dist/ui/bridge.js +6 -3
- package/dist/ui/look.js +21 -7
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +31 -25
- package/dist/vercel.js +77 -0
- package/package.json +3 -2
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') &&
|
|
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
|
-
|
|
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', '--
|
|
424
|
-
/** What each of those values may be: a project's name or id, a GitHub repository, a remote's name,
|
|
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
|
-
/**
|
|
434
|
-
function
|
|
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
|
-
|
|
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
|
|
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`)
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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,
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]) =>
|
|
1018
|
-
|
|
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
|
-
|
|
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
|
}
|