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