@stratusagent/permissions 0.12.6 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +17 -0
- package/dist/commands.js.map +1 -1
- package/dist/compound.d.ts +167 -0
- package/dist/compound.d.ts.map +1 -0
- package/dist/compound.js +899 -0
- package/dist/compound.js.map +1 -0
- package/dist/index.d.ts +32 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +188 -35
- package/dist/index.js.map +1 -1
- package/dist/whitelist.d.ts +17 -2
- package/dist/whitelist.d.ts.map +1 -1
- package/dist/whitelist.js +28 -4
- package/dist/whitelist.js.map +1 -1
- package/dist/workspace.d.ts +16 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +3 -3
- package/dist/workspace.js.map +1 -1
- package/package.json +2 -2
package/dist/compound.js
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { analyzeCommand, findMatchingScope, SAFE_COMMAND_SCOPES } from "./commands.js";
|
|
3
|
+
import { resolveReal, walk, within } from "./workspace.js";
|
|
4
|
+
const disqualified = (command, reason) => ({ command, pipelines: [], disqualifiedBy: reason });
|
|
5
|
+
/** Words the shell reads as syntax when they start a command. */
|
|
6
|
+
const SHELL_KEYWORDS = new Set([
|
|
7
|
+
'if', 'then', 'else', 'elif', 'fi', 'case', 'esac', 'for', 'select', 'while', 'until', 'do', 'done', 'in',
|
|
8
|
+
'function', 'time', 'coproc', '!', '{', '}', '[[', ']]',
|
|
9
|
+
]);
|
|
10
|
+
/**
|
|
11
|
+
* Builtins that change the shell they run in: what a later command finds on
|
|
12
|
+
* `PATH`, what a name means, what happens on exit. On its own each changes a
|
|
13
|
+
* shell that ends with the call; in a line with others it changes what the
|
|
14
|
+
* rest of the line runs (`export PATH=.; uname` runs a workspace `uname`),
|
|
15
|
+
* which no check of the later command sees. `cd` is the one this reader
|
|
16
|
+
* follows. `printf -v` assigns too, and is caught by its flag.
|
|
17
|
+
*/
|
|
18
|
+
const SHELL_STATE = new Set([
|
|
19
|
+
'export', 'readonly', 'declare', 'typeset', 'local', 'set', 'unset', 'alias', 'unalias', 'hash', 'rehash',
|
|
20
|
+
'shopt', 'setopt', 'unsetopt', 'enable', 'disable', 'trap', 'umask', 'ulimit', 'limit', 'pushd', 'popd',
|
|
21
|
+
'read', 'mapfile', 'readarray', 'let', 'shift', 'getopts', 'bind', 'fc', 'history', 'autoload', 'zmodload',
|
|
22
|
+
'functions', 'unfunction', 'emulate', 'setenv', 'unsetenv',
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Builtins that run their first argument as a command in the current shell:
|
|
26
|
+
* `command export PATH=.` is an export. Looked through to the command named.
|
|
27
|
+
*/
|
|
28
|
+
const SHELL_WRAPPERS = new Set(['command', 'builtin', 'noglob', 'nocorrect']);
|
|
29
|
+
/** Builtins that run text the line does not show, in the current shell. */
|
|
30
|
+
const SHELL_EVALUATORS = new Set(['eval', 'source', '.']);
|
|
31
|
+
const changesShell = (analysis) => {
|
|
32
|
+
const tokens = analysis.tokens;
|
|
33
|
+
let index = 0;
|
|
34
|
+
while (index < tokens.length && SHELL_WRAPPERS.has(tokens[index])) {
|
|
35
|
+
const wrapper = tokens[index];
|
|
36
|
+
index += 1;
|
|
37
|
+
for (; index < tokens.length && tokens[index].startsWith('-'); index += 1) {
|
|
38
|
+
if (tokens[index] === '--') {
|
|
39
|
+
index += 1;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
// `command -v`/`-V` only says what a name is; nothing runs.
|
|
43
|
+
if (wrapper === 'command' && /^-[^-]*[vV]/.test(tokens[index])) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const base = tokens[index];
|
|
49
|
+
if (base === undefined)
|
|
50
|
+
return false;
|
|
51
|
+
return SHELL_STATE.has(base) || SHELL_EVALUATORS.has(base)
|
|
52
|
+
|| (base === 'printf' && tokens.slice(index + 1).some((token) => /^-[^-]*v/.test(token)));
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A redirection target the shell would expand, or that this reader would get
|
|
56
|
+
* wrong. `!` is there for zsh and tcsh, which read `>!` as an operator: the
|
|
57
|
+
* target is the next word, not `!`.
|
|
58
|
+
*/
|
|
59
|
+
const UNREADABLE_TARGET = /[*?[\]{}~$`\\#!]/;
|
|
60
|
+
/**
|
|
61
|
+
* Read a command line as a list of pipelines.
|
|
62
|
+
*
|
|
63
|
+
* Splits on `&&`, `||`, `;` and `|` outside quotes, and lifts `>`, `>>`,
|
|
64
|
+
* `<` and descriptor copies out of each command. Everything else the
|
|
65
|
+
* shell treats as syntax refuses the line: a lone `&` or `&>` (a background job that
|
|
66
|
+
* outlives the call), a newline, parentheses, `$( )`, backticks, a heredoc,
|
|
67
|
+
* an unquoted backslash or `#`. What is left of each command is then read by
|
|
68
|
+
* `analyzeCommand`, which refuses whatever it would refuse on its own.
|
|
69
|
+
*/
|
|
70
|
+
export const analyzeCompound = (command) => {
|
|
71
|
+
const pipelines = [];
|
|
72
|
+
let stages = [];
|
|
73
|
+
let text = '';
|
|
74
|
+
let redirects = [];
|
|
75
|
+
// Where the word being written began in `text`, so a descriptor number
|
|
76
|
+
// written against its operator (`2>`) can be taken back out of it.
|
|
77
|
+
let wordStart = 0;
|
|
78
|
+
let quote;
|
|
79
|
+
const endStage = () => {
|
|
80
|
+
const trimmed = text.trim();
|
|
81
|
+
if (trimmed.length === 0) {
|
|
82
|
+
return 'it contains an empty command (a stray operator)';
|
|
83
|
+
}
|
|
84
|
+
const analysis = analyzeCommand(trimmed);
|
|
85
|
+
if (analysis.disqualifiedBy) {
|
|
86
|
+
return analysis.disqualifiedBy;
|
|
87
|
+
}
|
|
88
|
+
// `if true; then sh -c id; fi` split on `;` is not three commands named
|
|
89
|
+
// `if`, `then` and `fi`: the keyword is syntax, and the command it
|
|
90
|
+
// carries is the next word. This reader does not model it.
|
|
91
|
+
if (SHELL_KEYWORDS.has(analysis.tokens[0] ?? '')) {
|
|
92
|
+
return 'it contains shell syntax (if, for, while, case, braces)';
|
|
93
|
+
}
|
|
94
|
+
if (analysis.pipeline) {
|
|
95
|
+
// `analyzeCommand` split on a pipe this reader did not see.
|
|
96
|
+
return 'it could not be read as a command';
|
|
97
|
+
}
|
|
98
|
+
stages.push({ analysis, redirects });
|
|
99
|
+
text = '';
|
|
100
|
+
redirects = [];
|
|
101
|
+
wordStart = 0;
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
const endPipeline = () => {
|
|
105
|
+
const failed = endStage();
|
|
106
|
+
if (failed) {
|
|
107
|
+
return failed;
|
|
108
|
+
}
|
|
109
|
+
pipelines.push(stages);
|
|
110
|
+
stages = [];
|
|
111
|
+
return undefined;
|
|
112
|
+
};
|
|
113
|
+
/** Read the word after a redirection operator, quotes resolved. */
|
|
114
|
+
const readTarget = (from) => {
|
|
115
|
+
let index = from;
|
|
116
|
+
while (command[index] === ' ' || command[index] === '\t') {
|
|
117
|
+
index += 1;
|
|
118
|
+
}
|
|
119
|
+
let target = '';
|
|
120
|
+
let inQuote;
|
|
121
|
+
let started = false;
|
|
122
|
+
for (; index < command.length; index += 1) {
|
|
123
|
+
const char = command[index];
|
|
124
|
+
if (inQuote) {
|
|
125
|
+
if (char === inQuote) {
|
|
126
|
+
inQuote = undefined;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
target += char;
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (char === '"' || char === "'") {
|
|
134
|
+
inQuote = char;
|
|
135
|
+
started = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (/[\s;&|<>()]/.test(char)) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
target += char;
|
|
142
|
+
started = true;
|
|
143
|
+
}
|
|
144
|
+
if (inQuote || !started || target.length === 0 || UNREADABLE_TARGET.test(target)) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return { target, next: index };
|
|
148
|
+
};
|
|
149
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
150
|
+
const char = command[index];
|
|
151
|
+
const next = command[index + 1];
|
|
152
|
+
if (quote === "'") {
|
|
153
|
+
if (char === "'") {
|
|
154
|
+
quote = undefined;
|
|
155
|
+
}
|
|
156
|
+
text += char;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (quote === '"') {
|
|
160
|
+
if (char === '\\' && next !== undefined) {
|
|
161
|
+
text += char + next;
|
|
162
|
+
index += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (char === '"') {
|
|
166
|
+
quote = undefined;
|
|
167
|
+
}
|
|
168
|
+
else if (char === '`' || (char === '$' && next === '(')) {
|
|
169
|
+
return disqualified(command, 'it contains a command substitution');
|
|
170
|
+
}
|
|
171
|
+
text += char;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (char === '"' || char === "'") {
|
|
175
|
+
quote = char;
|
|
176
|
+
text += char;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (char === '\\') {
|
|
180
|
+
return disqualified(command, 'it contains a backslash outside quotes');
|
|
181
|
+
}
|
|
182
|
+
if (char === '#') {
|
|
183
|
+
return disqualified(command, 'it contains a # outside quotes');
|
|
184
|
+
}
|
|
185
|
+
if (char === '\n' || char === '\r') {
|
|
186
|
+
return disqualified(command, 'it contains a newline');
|
|
187
|
+
}
|
|
188
|
+
if (char === '(' || char === ')') {
|
|
189
|
+
return disqualified(command, 'it contains a subshell (parentheses)');
|
|
190
|
+
}
|
|
191
|
+
if (char === '`' || (char === '$' && next === '(')) {
|
|
192
|
+
return disqualified(command, 'it contains a command substitution');
|
|
193
|
+
}
|
|
194
|
+
if (char === ';') {
|
|
195
|
+
const failed = endPipeline();
|
|
196
|
+
if (failed) {
|
|
197
|
+
return disqualified(command, failed);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (char === '&' && next === '&') {
|
|
202
|
+
const failed = endPipeline();
|
|
203
|
+
if (failed) {
|
|
204
|
+
return disqualified(command, failed);
|
|
205
|
+
}
|
|
206
|
+
index += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (char === '|' && next === '|') {
|
|
210
|
+
const failed = endPipeline();
|
|
211
|
+
if (failed) {
|
|
212
|
+
return disqualified(command, failed);
|
|
213
|
+
}
|
|
214
|
+
index += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (char === '|') {
|
|
218
|
+
if (next === '&') {
|
|
219
|
+
return disqualified(command, 'it pipes standard error (|&)');
|
|
220
|
+
}
|
|
221
|
+
const failed = endStage();
|
|
222
|
+
if (failed) {
|
|
223
|
+
return disqualified(command, failed);
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === '>' || char === '<' || (char === '&' && next === '>')) {
|
|
228
|
+
// A descriptor number is part of the operator only when it is the
|
|
229
|
+
// whole word so far: `2>err` redirects stderr, `a2>err` is `a2`.
|
|
230
|
+
const word = text.slice(wordStart);
|
|
231
|
+
if (/^\d+$/.test(word)) {
|
|
232
|
+
text = text.slice(0, wordStart);
|
|
233
|
+
}
|
|
234
|
+
else if (/^\{[^{}]*\}$/.test(word)) {
|
|
235
|
+
// bash reads `{fd}>out` as a redirection that allocates a descriptor
|
|
236
|
+
// into $fd, so `{fd}` is not a word of the command. Refused rather
|
|
237
|
+
// than modeled: sh and dash read it as an argument.
|
|
238
|
+
return disqualified(command, 'it names a descriptor with {name} before a redirection');
|
|
239
|
+
}
|
|
240
|
+
let at = index;
|
|
241
|
+
let direction;
|
|
242
|
+
if (char === '&') {
|
|
243
|
+
// `&>` is bash's both-streams redirection, but dash and POSIX sh
|
|
244
|
+
// read `a &> out` as `a &` (a background job) and then `> out`.
|
|
245
|
+
return disqualified(command, 'it uses &>, which sh reads as a background job (write >out 2>&1)');
|
|
246
|
+
}
|
|
247
|
+
else if (char === '>') {
|
|
248
|
+
direction = 'out';
|
|
249
|
+
if (next === '>') {
|
|
250
|
+
at += 1;
|
|
251
|
+
}
|
|
252
|
+
else if (next === '|') {
|
|
253
|
+
return disqualified(command, 'it contains a clobbering redirection (>|)');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
direction = 'in';
|
|
258
|
+
if (next === '<') {
|
|
259
|
+
return disqualified(command, 'it contains a heredoc (<<)');
|
|
260
|
+
}
|
|
261
|
+
if (next === '>') {
|
|
262
|
+
return disqualified(command, 'it opens a file for reading and writing (<>)');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (command[at + 1] === '&') {
|
|
266
|
+
// A descriptor copy: `2>&1`, `>&2`, `<&0`, or `>&-` to close one.
|
|
267
|
+
const copy = /^&(\d+|-)/.exec(command.slice(at + 1));
|
|
268
|
+
if (!copy) {
|
|
269
|
+
return disqualified(command, 'it redirects to something other than a file or a descriptor');
|
|
270
|
+
}
|
|
271
|
+
index = at + copy[0].length;
|
|
272
|
+
text += ' ';
|
|
273
|
+
wordStart = text.length;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const read = readTarget(at + 1);
|
|
277
|
+
if (!read) {
|
|
278
|
+
return disqualified(command, 'it redirects to a path this reader cannot be sure of');
|
|
279
|
+
}
|
|
280
|
+
redirects.push({ direction, target: read.target });
|
|
281
|
+
index = read.next - 1;
|
|
282
|
+
text += ' ';
|
|
283
|
+
wordStart = text.length;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (char === '&') {
|
|
287
|
+
return disqualified(command, 'it runs a command in the background (&)');
|
|
288
|
+
}
|
|
289
|
+
if (char === ' ' || char === '\t') {
|
|
290
|
+
text += char;
|
|
291
|
+
wordStart = text.length;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
text += char;
|
|
295
|
+
}
|
|
296
|
+
if (quote) {
|
|
297
|
+
return disqualified(command, 'it has an unbalanced quote');
|
|
298
|
+
}
|
|
299
|
+
const failed = endPipeline();
|
|
300
|
+
if (failed) {
|
|
301
|
+
return disqualified(command, failed);
|
|
302
|
+
}
|
|
303
|
+
const all = pipelines.flat();
|
|
304
|
+
if (all.length > 1 && all.some((stage) => changesShell(stage.analysis))) {
|
|
305
|
+
return disqualified(command, 'it changes the shell the rest of the line runs in (export, alias, set)');
|
|
306
|
+
}
|
|
307
|
+
return { command, pipelines };
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Commands that may be granted by name: each reads or writes the paths it
|
|
311
|
+
* is given and nothing else, so a check of those paths is a check of what
|
|
312
|
+
* it does. Network clients, archivers, test runners, linters, build tools
|
|
313
|
+
* and `jq` (whose `env` reads the daemon's environment) are not on it;
|
|
314
|
+
* they keep the narrower scope. Neither is `printf`, whose bash builtin
|
|
315
|
+
* assigns a variable with `-v`, nor binutils `strings`, which reads more
|
|
316
|
+
* arguments from the file an `@file` names, nor `cp`, which writes
|
|
317
|
+
* `dest/source` for `cp source dest` and follows a symlink already there,
|
|
318
|
+
* so its real destinations are paths no operand names, nor `diff`, which
|
|
319
|
+
* for two directories (or a directory and a file) reads their children and
|
|
320
|
+
* follows a child symlink, paths no operand names either, nor `file`,
|
|
321
|
+
* which prefers a magic database in `$HOME` (`~/.magic.mgc`) or `MAGIC`
|
|
322
|
+
* over the system one, nor `shasum`, a Perl script on macOS that
|
|
323
|
+
* `PERL5OPT`/`PERL5LIB` load modules into (`sha256sum` and friends cover
|
|
324
|
+
* it). Adding one means reading its manual and
|
|
325
|
+
* listing, in `NAME_GRANT_FLAGS`, only the flags that act on the paths
|
|
326
|
+
* given and nothing else.
|
|
327
|
+
*/
|
|
328
|
+
export const GRANTABLE_BY_NAME = new Set([
|
|
329
|
+
'grep', 'egrep', 'fgrep', 'rg',
|
|
330
|
+
'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'paste', 'join', 'comm', 'fold', 'nl', 'column', 'rev', 'tac',
|
|
331
|
+
'cmp', 'ls', 'stat', 'du', 'tree', 'realpath', 'readlink', 'basename', 'dirname',
|
|
332
|
+
'mkdir', 'touch', 'mv', 'rm', 'rmdir', 'ln', 'tee',
|
|
333
|
+
'md5', 'md5sum', 'sha1sum', 'sha256sum', 'sha512sum', 'base64', 'od', 'xxd', 'hexdump',
|
|
334
|
+
'gzip', 'gunzip', 'zcat', 'test',
|
|
335
|
+
]);
|
|
336
|
+
/**
|
|
337
|
+
* Commands no grant may ever cover by name, and which get no scope either
|
|
338
|
+
* when they appear in a line with others (`… | sh`). Each one can be
|
|
339
|
+
* pointed at something no path check
|
|
340
|
+
* sees: a program to run (`sh`, `python`, `xargs`, `env`, `find -exec`), a
|
|
341
|
+
* privilege (`sudo`), a remote machine (`ssh`), or the machine's own
|
|
342
|
+
* configuration (`launchctl`, `networksetup`, `security`, `chmod`). They
|
|
343
|
+
* keep the narrower scope an "always" has always stored.
|
|
344
|
+
*/
|
|
345
|
+
export const NOT_GRANTABLE_BY_NAME = new Set([
|
|
346
|
+
// Privilege.
|
|
347
|
+
'sudo', 'su', 'doas', 'pkexec',
|
|
348
|
+
// Shells and interpreters: the argument is a program.
|
|
349
|
+
'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'nu', 'pwsh',
|
|
350
|
+
'python', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'lua', 'osascript', 'tclsh',
|
|
351
|
+
'awk', 'gawk', 'mawk', 'sed', 'jshell', 'irb',
|
|
352
|
+
// Wrappers: they run whatever follows.
|
|
353
|
+
'env', 'xargs', 'eval', 'exec', 'command', 'builtin', 'source', '.', 'nohup', 'timeout', 'gtimeout', 'nice',
|
|
354
|
+
'time', 'watch', 'parallel', 'find', 'fd', 'npx', 'pnpx', 'bunx', 'uvx', 'script', 'expect', 'caffeinate',
|
|
355
|
+
'arch', 'chroot', 'setsid', 'stdbuf', 'flock', 'strace', 'ltrace', 'dtruss', 'dtrace', 'gdb', 'lldb',
|
|
356
|
+
'sandbox-exec', 'xcrun', 'entr', 'watchexec', 'nodemon', 'java', 'julia', 'R', 'Rscript', 'dotnet', 'powershell',
|
|
357
|
+
// Archivers that store what a symlink points at by default.
|
|
358
|
+
'zip', '7z', '7za',
|
|
359
|
+
// Editors and pagers, which can shell out.
|
|
360
|
+
'vi', 'vim', 'nvim', 'nano', 'emacs', 'less', 'more', 'man',
|
|
361
|
+
// Other machines.
|
|
362
|
+
'ssh', 'scp', 'sftp', 'rsync', 'telnet', 'nc', 'ncat', 'socat',
|
|
363
|
+
// The machine itself: services, network, credentials, ownership, disks, processes.
|
|
364
|
+
'launchctl', 'systemctl', 'service', 'networksetup', 'ifconfig', 'ip', 'route', 'pfctl', 'iptables', 'scutil',
|
|
365
|
+
'systemsetup', 'dscl', 'defaults', 'security', 'chown', 'chgrp', 'chmod', 'chflags', 'xattr', 'dd', 'mkfs',
|
|
366
|
+
'diskutil', 'mount', 'umount', 'kill', 'killall', 'pkill', 'shutdown', 'reboot', 'halt', 'crontab', 'at',
|
|
367
|
+
'open', 'sqlite3', 'docker', 'podman',
|
|
368
|
+
// Stratus itself: its state lives outside the workspace on purpose.
|
|
369
|
+
'stratus',
|
|
370
|
+
]);
|
|
371
|
+
/**
|
|
372
|
+
* Commands whose first word is a subcommand, so a grant on the bare name
|
|
373
|
+
* would cover every subcommand: `gh pr view` and `gh repo delete`. These
|
|
374
|
+
* keep the command-plus-subcommand scope the engine has always stored, with
|
|
375
|
+
* its destructive-flag limits (`git push` but not `git push --force`).
|
|
376
|
+
*/
|
|
377
|
+
export const SUBCOMMAND_COMMANDS = new Set([
|
|
378
|
+
'git', 'gh', 'glab', 'npm', 'pnpm', 'yarn', 'cargo', 'go', 'brew', 'fly', 'flyctl', 'agentboard', 'gcloud',
|
|
379
|
+
'aws', 'az', 'terraform', 'kubectl', 'helm', 'pip', 'pip3', 'uv', 'poetry', 'rustup', 'swift', 'xcodebuild',
|
|
380
|
+
'gem', 'bundle', 'make', 'apt', 'apt-get', 'dnf', 'yum', 'corepack', 'nvm', 'mise', 'asdf', 'tmux',
|
|
381
|
+
]);
|
|
382
|
+
/** Whether "always" on this command may grant it by name. */
|
|
383
|
+
export const grantableByName = (base) => GRANTABLE_BY_NAME.has(base)
|
|
384
|
+
&& !NOT_GRANTABLE_BY_NAME.has(base)
|
|
385
|
+
&& !SUBCOMMAND_COMMANDS.has(base);
|
|
386
|
+
const GREP_FLAGS = {
|
|
387
|
+
short: 'ivnlLcwxhHoqsrEFGPzZabUT',
|
|
388
|
+
value: 'efmABC',
|
|
389
|
+
long: [
|
|
390
|
+
'--ignore-case', '--no-ignore-case', '--invert-match', '--line-number', '--files-with-matches',
|
|
391
|
+
'--files-without-match', '--count', '--word-regexp', '--line-regexp', '--no-filename', '--with-filename',
|
|
392
|
+
'--only-matching', '--quiet', '--silent', '--no-messages', '--recursive', '--extended-regexp',
|
|
393
|
+
'--fixed-strings', '--basic-regexp', '--perl-regexp', '--null', '--null-data', '--text', '--binary-files',
|
|
394
|
+
'--include', '--exclude', '--exclude-dir', '--regexp', '--file', '--max-count', '--after-context',
|
|
395
|
+
'--before-context', '--context', '--color', '--colour', '--byte-offset', '--initial-tab',
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
const SUM_FLAGS = { short: 'btz', long: ['--binary', '--text', '--tag', '--zero'] };
|
|
399
|
+
const GZIP_FLAGS = {
|
|
400
|
+
short: 'dckfnNqvtl123456789',
|
|
401
|
+
value: 'S',
|
|
402
|
+
long: ['--decompress', '--stdout', '--keep', '--force', '--quiet', '--verbose', '--test', '--list', '--suffix', '--fast', '--best'],
|
|
403
|
+
};
|
|
404
|
+
const NAME_GRANT_FLAGS = {
|
|
405
|
+
grep: GREP_FLAGS,
|
|
406
|
+
egrep: GREP_FLAGS,
|
|
407
|
+
fgrep: GREP_FLAGS,
|
|
408
|
+
rg: {
|
|
409
|
+
short: 'ivnNlcwxHIoqsSFuU0',
|
|
410
|
+
value: 'egtTmABCjM',
|
|
411
|
+
long: [
|
|
412
|
+
'--ignore-case', '--smart-case', '--case-sensitive', '--invert-match', '--line-number', '--no-line-number',
|
|
413
|
+
'--files-with-matches', '--files-without-match', '--count', '--count-matches', '--word-regexp',
|
|
414
|
+
'--line-regexp', '--with-filename', '--no-filename', '--only-matching', '--quiet', '--fixed-strings',
|
|
415
|
+
'--hidden', '--no-ignore', '--glob', '--iglob', '--type', '--type-not', '--max-count', '--after-context',
|
|
416
|
+
'--before-context', '--context', '--files', '--json', '--color', '--heading', '--no-heading', '--sort',
|
|
417
|
+
'--max-depth', '--multiline', '--regexp', '--vimgrep', '--trim', '--column', '--null', '--max-columns',
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
cat: { short: 'nbsvetAETu', long: ['--number', '--number-nonblank', '--squeeze-blank', '--show-all', '--show-ends', '--show-tabs', '--show-nonprinting'] },
|
|
421
|
+
head: { short: 'qv', value: 'nc', long: ['--lines', '--bytes', '--quiet', '--silent', '--verbose'], numeric: true },
|
|
422
|
+
tail: { short: 'qvr', value: 'nc', long: ['--lines', '--bytes', '--quiet', '--silent', '--verbose'], numeric: true },
|
|
423
|
+
wc: { short: 'lwcmL', long: ['--lines', '--words', '--bytes', '--chars', '--max-line-length'] },
|
|
424
|
+
sort: {
|
|
425
|
+
short: 'rnuhfbdgiMRVsczm',
|
|
426
|
+
value: 'tkoS',
|
|
427
|
+
long: [
|
|
428
|
+
'--reverse', '--numeric-sort', '--unique', '--human-numeric-sort', '--ignore-case', '--key',
|
|
429
|
+
'--field-separator', '--output', '--stable', '--version-sort', '--general-numeric-sort', '--month-sort',
|
|
430
|
+
'--random-sort', '--dictionary-order', '--ignore-leading-blanks', '--check', '--merge', '--zero-terminated',
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
uniq: { short: 'cdDiuz', value: 'fsw', long: ['--count', '--repeated', '--ignore-case', '--unique', '--skip-fields', '--skip-chars'] },
|
|
434
|
+
cut: { short: 'sz', value: 'bcfd', long: ['--bytes', '--characters', '--fields', '--delimiter', '--only-delimited', '--complement', '--output-delimiter'] },
|
|
435
|
+
tr: { short: 'cCdsu' },
|
|
436
|
+
paste: { short: 'sz', value: 'd', long: ['--serial', '--delimiters'] },
|
|
437
|
+
join: { short: 'i', value: '12jtoeav', long: ['--ignore-case'] },
|
|
438
|
+
comm: { short: '123i', long: ['--check-order', '--nocheck-order', '--output-delimiter'] },
|
|
439
|
+
fold: { short: 'bs', value: 'w', long: ['--bytes', '--spaces', '--width'] },
|
|
440
|
+
nl: { value: 'bnsvwi' },
|
|
441
|
+
column: { short: 'tx', value: 'sco' },
|
|
442
|
+
rev: {},
|
|
443
|
+
tac: { short: 'br', value: 's' },
|
|
444
|
+
cmp: { short: 'lsb', value: 'in', long: ['--verbose', '--silent', '--quiet', '--print-bytes'] },
|
|
445
|
+
ls: { short: '1aAlhtrSRdFpiGnosucfgkT', long: ['--all', '--almost-all', '--human-readable', '--recursive', '--directory', '--color', '--classify'] },
|
|
446
|
+
stat: { short: 'xsrnql', value: 'fct' },
|
|
447
|
+
du: { short: 'shackmx', value: 'd', long: ['--summarize', '--human-readable', '--all', '--max-depth', '--total', '--apparent-size'] },
|
|
448
|
+
tree: { short: 'adfFgpusDhCnJ', value: 'LIPo' },
|
|
449
|
+
realpath: { short: 'qsezmP' },
|
|
450
|
+
readlink: { short: 'fenqsvzm' },
|
|
451
|
+
basename: { short: 'az', value: 's' },
|
|
452
|
+
dirname: { short: 'z' },
|
|
453
|
+
mkdir: { short: 'pv', value: 'm', long: ['--parents', '--verbose'] },
|
|
454
|
+
touch: { short: 'acmh', value: 'dtr' },
|
|
455
|
+
mv: { short: 'fivn', long: ['--force', '--interactive', '--no-clobber', '--verbose'] },
|
|
456
|
+
rm: { short: 'rRfivd', long: ['--recursive', '--force', '--interactive', '--verbose', '--dir'] },
|
|
457
|
+
// No -p/--parents: it removes every empty ancestor of the operand, so an
|
|
458
|
+
// absolute in-root path walks up past the grant root.
|
|
459
|
+
rmdir: { short: 'v', long: ['--verbose'] },
|
|
460
|
+
ln: { short: 'sfnvhi', long: ['--symbolic', '--force', '--no-dereference', '--verbose'] },
|
|
461
|
+
tee: { short: 'ai', long: ['--append', '--ignore-interrupts'] },
|
|
462
|
+
md5: { short: 'qrn', value: 's' },
|
|
463
|
+
md5sum: SUM_FLAGS,
|
|
464
|
+
sha1sum: SUM_FLAGS,
|
|
465
|
+
sha256sum: SUM_FLAGS,
|
|
466
|
+
sha512sum: SUM_FLAGS,
|
|
467
|
+
base64: { short: 'dD', value: 'iowb', long: ['--decode', '--wrap'] },
|
|
468
|
+
od: { short: 'cxbdov', value: 'AtNjw' },
|
|
469
|
+
xxd: { short: 'rpiuEe', value: 'lcgos' },
|
|
470
|
+
hexdump: { short: 'Cbcdovx', value: 'nsef' },
|
|
471
|
+
gzip: GZIP_FLAGS,
|
|
472
|
+
gunzip: GZIP_FLAGS,
|
|
473
|
+
zcat: GZIP_FLAGS,
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Whether every flag in the arguments is one `NAME_GRANT_FLAGS` lists for
|
|
477
|
+
* this command. Every word that starts with `-` is read as a flag, even a
|
|
478
|
+
* word that follows a flag taking a value, and even after `--`: in `rg -e
|
|
479
|
+
* -- --pre=x` the `--` is the pattern, not the end of the options. Reading
|
|
480
|
+
* `grep -- -R` as asking costs a prompt, where reading a flag as a value
|
|
481
|
+
* would cost a read nobody approved.
|
|
482
|
+
*/
|
|
483
|
+
const flagsCovered = (base, args) => {
|
|
484
|
+
// `test` reads its operators (`-f`, `-nt`) as tests, never as options.
|
|
485
|
+
if (base === 'test') {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
const flags = NAME_GRANT_FLAGS[base];
|
|
489
|
+
if (!flags) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
for (const token of args) {
|
|
493
|
+
if (token === '-' || token === '--' || !token.startsWith('-')) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (token.startsWith('--')) {
|
|
497
|
+
if (!(flags.long ?? []).includes(token.split('=')[0])) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (flags.numeric && /^-\d+$/.test(token)) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
for (const letter of token.slice(1)) {
|
|
506
|
+
if ((flags.value ?? '').includes(letter)) {
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
if (!(flags.short ?? '').includes(letter)) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return true;
|
|
515
|
+
};
|
|
516
|
+
/** The rg long flags in `NAME_GRANT_FLAGS` that take the next word as their value. */
|
|
517
|
+
const RG_LONG_VALUE = new Set([
|
|
518
|
+
'--glob', '--iglob', '--type', '--type-not', '--max-count', '--after-context', '--before-context',
|
|
519
|
+
'--context', '--regexp', '--sort', '--max-depth', '--max-columns', '--color',
|
|
520
|
+
]);
|
|
521
|
+
/**
|
|
522
|
+
* Whether an rg call turns off every ignore file. Without `--no-ignore`
|
|
523
|
+
* (or `-u`), ripgrep opens `.ignore`, `.rgignore` and `.gitignore` files,
|
|
524
|
+
* ignore files above the searched directory, the user's global one and a
|
|
525
|
+
* linked worktree's `info/exclude`, and none of them is an operand a path
|
|
526
|
+
* check sees. Only a word that is really a flag counts: in `rg -e
|
|
527
|
+
* --no-ignore` it is the pattern, and after `--` it is a path.
|
|
528
|
+
*/
|
|
529
|
+
const rgIgnoresOff = (args) => {
|
|
530
|
+
const flags = NAME_GRANT_FLAGS.rg;
|
|
531
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
532
|
+
const token = args[index];
|
|
533
|
+
if (token === '--') {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
if (token === '-' || !token.startsWith('-')) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (token.startsWith('--')) {
|
|
540
|
+
if (token === '--no-ignore') {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
if (!token.includes('=') && RG_LONG_VALUE.has(token)) {
|
|
544
|
+
index += 1;
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
for (const [at, letter] of [...token.slice(1)].entries()) {
|
|
549
|
+
if ((flags.value ?? '').includes(letter)) {
|
|
550
|
+
// The rest of the word is the value, or the next word is.
|
|
551
|
+
if (at === token.length - 2) {
|
|
552
|
+
index += 1;
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
if (letter === 'u') {
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
};
|
|
563
|
+
/** The readers that never ask inside the workspace, with or without autonomy. */
|
|
564
|
+
const WORKSPACE_READERS = new Set(['cat', 'head', 'tail', 'ls']);
|
|
565
|
+
/**
|
|
566
|
+
* Every string in a command's arguments that could name a path: each
|
|
567
|
+
* positional, the value of `--flag=value`, and every tail of a short-flag
|
|
568
|
+
* bundle, since any letter in it may be the one that takes the rest as its
|
|
569
|
+
* value (`-o/tmp/x`, `-ro/tmp/x`, and BSD and GNU disagree on which letters
|
|
570
|
+
* do). Over-inclusive on purpose:
|
|
571
|
+
* a grep pattern is checked as though it were a path, which costs a prompt
|
|
572
|
+
* for a pattern like `/etc/` and never a read nobody approved.
|
|
573
|
+
*/
|
|
574
|
+
const pathCandidates = (args) => {
|
|
575
|
+
const candidates = [];
|
|
576
|
+
for (const token of args) {
|
|
577
|
+
if (token === '-' || token === '--') {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (token.startsWith('--')) {
|
|
581
|
+
const at = token.indexOf('=');
|
|
582
|
+
if (at !== -1) {
|
|
583
|
+
candidates.push(token.slice(at + 1));
|
|
584
|
+
}
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (token.startsWith('-')) {
|
|
588
|
+
const at = token.indexOf('=');
|
|
589
|
+
if (at !== -1) {
|
|
590
|
+
candidates.push(token.slice(at + 1));
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
for (let at = 2; at < token.length; at += 1) {
|
|
594
|
+
candidates.push(token.slice(at));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
candidates.push(token);
|
|
600
|
+
}
|
|
601
|
+
// A path can hide inside a word as well as be one: `file=@/etc/hosts`,
|
|
602
|
+
// `-d@~/.netrc`, `a,b,/c`. Every remainder after a separator is checked
|
|
603
|
+
// too. A web URL is not a local path and is left alone; any other
|
|
604
|
+
// scheme (`file://`) is judged as one.
|
|
605
|
+
const expanded = [];
|
|
606
|
+
for (const candidate of candidates) {
|
|
607
|
+
if (/^https?:\/\//i.test(candidate)) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
expanded.push(candidate);
|
|
611
|
+
for (let at = 0; at < candidate.length; at += 1) {
|
|
612
|
+
if ('=@,'.includes(candidate[at])) {
|
|
613
|
+
expanded.push(candidate.slice(at + 1));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:(.*)$/.exec(candidate);
|
|
617
|
+
if (scheme) {
|
|
618
|
+
expanded.push(scheme[1].replace(/^\/\/[^/]*/, ''));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return expanded.filter((candidate) => candidate.length > 0);
|
|
622
|
+
};
|
|
623
|
+
const GLOB = /[*?[]/;
|
|
624
|
+
/**
|
|
625
|
+
* A shell pattern for one path segment, as a regular expression that
|
|
626
|
+
* matches at least what the shell would. A bracket expression is read as
|
|
627
|
+
* any one character, which over-matches and so only ever checks more.
|
|
628
|
+
*/
|
|
629
|
+
const segmentPattern = (pattern) => {
|
|
630
|
+
let source = '';
|
|
631
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
632
|
+
const char = pattern[index];
|
|
633
|
+
if (char === '*') {
|
|
634
|
+
source += '.*';
|
|
635
|
+
}
|
|
636
|
+
else if (char === '?') {
|
|
637
|
+
source += '.';
|
|
638
|
+
}
|
|
639
|
+
else if (char === '[') {
|
|
640
|
+
const close = pattern.indexOf(']', index + 2);
|
|
641
|
+
source += '.';
|
|
642
|
+
if (close !== -1) {
|
|
643
|
+
index = close;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
source += char.replace(/[.+^${}()|[\]\\/]/g, '\\$&');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return new RegExp(`^${source}$`, 's');
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Whether a word the shell will glob expands only to paths inside `root`,
|
|
654
|
+
* from each of `cwds`. Only a pattern in the last segment is judged, so the
|
|
655
|
+
* directory it matches in is known; each entry it matches must stay inside,
|
|
656
|
+
* symlinks followed, since `cat src/*` follows a link in `src` like a file.
|
|
657
|
+
* A bare pattern (no directory) that matches a name starting with `-` is
|
|
658
|
+
* refused, since the command would read that name as a flag. A pattern matching nothing is passed through as text, which is the
|
|
659
|
+
* caller's to judge.
|
|
660
|
+
*/
|
|
661
|
+
const globLandsInside = async (word, cwds, root) => {
|
|
662
|
+
const slash = word.lastIndexOf('/');
|
|
663
|
+
const directory = slash === -1 ? '.' : word.slice(0, slash + 1);
|
|
664
|
+
const last = word.slice(slash + 1);
|
|
665
|
+
if (GLOB.test(directory) || directory.split('/').includes('..')) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
const matcher = segmentPattern(last);
|
|
669
|
+
for (const cwd of cwds) {
|
|
670
|
+
const resolved = await walk(cwd, directory);
|
|
671
|
+
if (resolved === undefined || !within(root, resolved)) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
let entries;
|
|
675
|
+
try {
|
|
676
|
+
entries = await readdir(resolved);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
for (const entry of entries) {
|
|
682
|
+
if (entry.startsWith('.') && !last.startsWith('.')) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (!matcher.test(entry)) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
// A bare pattern expands to the names themselves, so a file named
|
|
689
|
+
// `--pre=sh` reaches the command as that flag, past every flag check.
|
|
690
|
+
if (slash === -1 && entry.startsWith('-')) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const landed = await walk(resolved, entry);
|
|
694
|
+
if (landed === undefined || !within(root, landed)) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return true;
|
|
700
|
+
};
|
|
701
|
+
/**
|
|
702
|
+
* Whether `candidate`, read as a literal path from each of `cwds`, stays
|
|
703
|
+
* inside `root`. `~`, `$`, braces and backticks have no literal reading.
|
|
704
|
+
*/
|
|
705
|
+
const landsInside = async (candidate, cwds, root) => {
|
|
706
|
+
if (/[$`{}~\\]/.test(candidate)) {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
for (const cwd of cwds) {
|
|
710
|
+
const resolved = await walk(cwd, candidate);
|
|
711
|
+
if (resolved === undefined || !within(root, resolved)) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return true;
|
|
716
|
+
};
|
|
717
|
+
/**
|
|
718
|
+
* A by-name grant on this command, if one is on file and the call keeps to
|
|
719
|
+
* the workspace: it runs in it, and every argument that could be a path
|
|
720
|
+
* resolves inside it, symlinks followed.
|
|
721
|
+
*/
|
|
722
|
+
const coveredByName = async (stage, names, cwds, root) => {
|
|
723
|
+
const base = stage.base;
|
|
724
|
+
if (base === undefined || !names.has(base) || !grantableByName(base)) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
const args = stage.tokens.slice(1);
|
|
728
|
+
if (!flagsCovered(base, args)) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
if (base === 'rg' && !rgIgnoresOff(args)) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (stage.tokens.some((token) => /[$`]/.test(token))) {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
for (const [index, token] of args.entries()) {
|
|
738
|
+
// The shell globs the whole word, before any of it means anything to
|
|
739
|
+
// the command: `--include=*.ts` matches files named `--include=…`.
|
|
740
|
+
if (stage.expands?.[index + 1] && GLOB.test(token) && !(await globLandsInside(token, cwds, root))) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const candidate of pathCandidates(args)) {
|
|
745
|
+
if (!(await landsInside(candidate, cwds, root))) {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return true;
|
|
750
|
+
};
|
|
751
|
+
/**
|
|
752
|
+
* Commands that may leave a path resolving somewhere it did not before,
|
|
753
|
+
* by moving a directory entry or making a link. Every path in a line is
|
|
754
|
+
* checked against the filesystem as it is before the line runs, so after one
|
|
755
|
+
* of these (or alongside one, in a pipeline) a later path check proves
|
|
756
|
+
* nothing: `mv staged safe && cat safe/link` reads whatever `staged/link`
|
|
757
|
+
* pointed at. Removing and creating plain files and directories relinks
|
|
758
|
+
* nothing, so `rm` and `mkdir` are not here. Any command this reader does
|
|
759
|
+
* not know the effects of counts, since a granted `git checkout` can write
|
|
760
|
+
* a symlink as easily as `ln`.
|
|
761
|
+
*/
|
|
762
|
+
const RELINKS = new Set(['mv', 'cp', 'ln']);
|
|
763
|
+
const KNOWN_NOT_TO_RELINK = new Set([
|
|
764
|
+
...[...GRANTABLE_BY_NAME].filter((base) => !RELINKS.has(base)),
|
|
765
|
+
...SAFE_COMMAND_SCOPES.map((scope) => scope.command),
|
|
766
|
+
]);
|
|
767
|
+
const mayRelink = (stage) => !KNOWN_NOT_TO_RELINK.has(stage.analysis.base ?? '');
|
|
768
|
+
/** Where a redirection may point: the workspace, or `/dev/null`. */
|
|
769
|
+
const redirectsInside = async (redirects, cwds, root, stale = false) => {
|
|
770
|
+
for (const redirect of redirects) {
|
|
771
|
+
if (redirect.direction === 'out' && redirect.target === '/dev/null') {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (stale || root === undefined || cwds === undefined || !(await landsInside(redirect.target, cwds, root))) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return true;
|
|
779
|
+
};
|
|
780
|
+
/**
|
|
781
|
+
* Whether every command of a compound line would run unattended where it
|
|
782
|
+
* runs.
|
|
783
|
+
*
|
|
784
|
+
* Directories are followed through `cd` as a set, not a single answer: a
|
|
785
|
+
* `cd` that fails leaves the line where it was, and after `;` or `||` the
|
|
786
|
+
* next command runs there. Every directory the line could be in must pass.
|
|
787
|
+
* A `cd` this reader cannot follow (no argument, `-`, a `~`) is itself
|
|
788
|
+
* fine, since changing directory touches nothing, but it leaves nothing
|
|
789
|
+
* after it that depends on the directory covered.
|
|
790
|
+
*/
|
|
791
|
+
export const judgeCompound = async (compound, options) => {
|
|
792
|
+
const uncovered = [];
|
|
793
|
+
let unfixable = false;
|
|
794
|
+
const root = options.root === undefined ? undefined : await resolveReal(options.root);
|
|
795
|
+
const start = options.cwd === undefined ? undefined : await resolveReal(options.cwd);
|
|
796
|
+
let cwds = start === undefined ? undefined : [start];
|
|
797
|
+
const names = new Set(options.names);
|
|
798
|
+
const insideRoot = () => root !== undefined && cwds !== undefined && cwds.every((cwd) => within(root, cwd));
|
|
799
|
+
// Set once a command that may relink a path could have run (`mayRelink`):
|
|
800
|
+
// from then on no path check made before the line ran covers anything.
|
|
801
|
+
let stale = false;
|
|
802
|
+
for (const pipeline of compound.pipelines) {
|
|
803
|
+
// The commands of a pipeline run at once, so one that relinks may run
|
|
804
|
+
// before any of the others.
|
|
805
|
+
if (pipeline.length > 1 && pipeline.some(mayRelink)) {
|
|
806
|
+
stale = true;
|
|
807
|
+
}
|
|
808
|
+
// A granted scope composes in a pipe only with other scopes, as it
|
|
809
|
+
// does on its own (`findCoveringScopes`): a `gh api` scope must not
|
|
810
|
+
// become the far end of `cat notes | gh api … --input -` because the
|
|
811
|
+
// read is in the workspace. A by-name grant may, since everything it
|
|
812
|
+
// could send it could already name as a path.
|
|
813
|
+
const alone = pipeline.length === 1
|
|
814
|
+
|| pipeline.every((stage) => findMatchingScope(stage.analysis, [...options.scopes, ...options.safeScopes]) !== undefined);
|
|
815
|
+
const targets = [];
|
|
816
|
+
for (const stage of pipeline) {
|
|
817
|
+
const { analysis } = stage;
|
|
818
|
+
if (analysis.base === 'cd') {
|
|
819
|
+
if (!(await redirectsInside(stage.redirects, cwds, root, stale))) {
|
|
820
|
+
uncovered.push(stage);
|
|
821
|
+
unfixable = true;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
// In a pipeline `cd` runs in a subshell and moves nothing.
|
|
825
|
+
if (pipeline.length > 1) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const args = analysis.tokens.slice(1);
|
|
829
|
+
const target = args.length === 1 && !args[0].startsWith('-') && !analysis.expands?.[1] ? args[0] : undefined;
|
|
830
|
+
if (target === undefined || cwds === undefined) {
|
|
831
|
+
cwds = undefined;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
for (const cwd of cwds) {
|
|
835
|
+
const landed = await walk(cwd, target);
|
|
836
|
+
if (landed === undefined) {
|
|
837
|
+
cwds = undefined;
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
targets.push(landed);
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
let covered = findMatchingScope(analysis, options.safeScopes) !== undefined
|
|
845
|
+
|| (alone && findMatchingScope(analysis, options.scopes) !== undefined);
|
|
846
|
+
if (!covered && !stale && cwds !== undefined && root !== undefined && insideRoot()) {
|
|
847
|
+
covered = await coveredByName(analysis, names, cwds, root);
|
|
848
|
+
if (!covered && WORKSPACE_READERS.has(analysis.base ?? '')) {
|
|
849
|
+
covered = (await Promise.all(cwds.map((cwd) => options.reads(analysis, cwd, root)))).every(Boolean);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!covered && !stale && cwds !== undefined && options.autonomous) {
|
|
853
|
+
covered = (await Promise.all(cwds.map((cwd) => options.autonomous(analysis, cwd)))).every(Boolean);
|
|
854
|
+
}
|
|
855
|
+
const wasStale = stale;
|
|
856
|
+
if (mayRelink(stage)) {
|
|
857
|
+
stale = true;
|
|
858
|
+
}
|
|
859
|
+
if (!(await redirectsInside(stage.redirects, cwds, root, wasStale))) {
|
|
860
|
+
uncovered.push(stage);
|
|
861
|
+
unfixable = true;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (!covered) {
|
|
865
|
+
uncovered.push(stage);
|
|
866
|
+
if (cwds === undefined || !insideRoot() || wasStale) {
|
|
867
|
+
unfixable = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (cwds !== undefined && targets.length > 0) {
|
|
872
|
+
cwds = [...new Set([...cwds, ...targets])];
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return { covered: uncovered.length === 0, uncovered, unfixable };
|
|
876
|
+
};
|
|
877
|
+
/** A name for a log line: the commands of a line, never their arguments. */
|
|
878
|
+
export const compoundCommandNames = (stages) => [...new Set(stages.map((stage) => stage.analysis.base).filter((base) => base !== undefined))];
|
|
879
|
+
/**
|
|
880
|
+
* Read one by-name grant out of a whitelist file, or refuse it. A command
|
|
881
|
+
* that can never be granted by name is refused here as well as when
|
|
882
|
+
* matching, so a hand-edited `sudo` is dropped from the listing rather than
|
|
883
|
+
* shown as a grant that does nothing.
|
|
884
|
+
*/
|
|
885
|
+
export const parseCommandNameGrant = (raw) => {
|
|
886
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
887
|
+
return undefined;
|
|
888
|
+
}
|
|
889
|
+
const source = raw;
|
|
890
|
+
if (typeof source.command !== 'string' || !grantableByName(source.command)) {
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
command: source.command,
|
|
895
|
+
grantedAt: typeof source.grantedAt === 'string' ? source.grantedAt : new Date(0).toISOString(),
|
|
896
|
+
...(typeof source.grantedBy === 'string' && source.grantedBy.length > 0 ? { grantedBy: source.grantedBy } : {}),
|
|
897
|
+
};
|
|
898
|
+
};
|
|
899
|
+
//# sourceMappingURL=compound.js.map
|