@stratusagent/permissions 0.11.8 → 0.11.10
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 +61 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +489 -13
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +17 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -16
- package/dist/index.js.map +1 -1
- package/dist/origins.d.ts +17 -4
- package/dist/origins.d.ts.map +1 -1
- package/dist/origins.js +20 -8
- package/dist/origins.js.map +1 -1
- package/dist/whitelist.d.ts +2 -0
- package/dist/whitelist.d.ts.map +1 -1
- package/dist/whitelist.js +6 -2
- package/dist/whitelist.js.map +1 -1
- package/dist/workspace.d.ts +27 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +753 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +2 -2
package/dist/commands.js
CHANGED
|
@@ -73,6 +73,11 @@ const DESTRUCTIVE_FLAGS = [
|
|
|
73
73
|
* error text, which the shell tool returns. A command that can be handed a
|
|
74
74
|
* path is a file reader wearing another name.
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* An `allowedFlags` entry admitting a flag that is only a number, the way
|
|
78
|
+
* `head -20` spells `head -n 20`. Not a letter, so no bundle can spell it.
|
|
79
|
+
*/
|
|
80
|
+
export const NUMERIC_FLAG = '-<number>';
|
|
76
81
|
export const SAFE_COMMAND_SCOPES = [
|
|
77
82
|
{ command: 'git', args: ['status'] },
|
|
78
83
|
{ command: 'git', args: ['log'] },
|
|
@@ -122,6 +127,67 @@ export const SAFE_COMMAND_SCOPES = [
|
|
|
122
127
|
allowedFlags: ['--verbose', 'v'],
|
|
123
128
|
deniedArgs: ['add', 'remove', 'rm', 'rename', 'set-url', 'set-head', 'set-branches', 'prune', 'update'],
|
|
124
129
|
},
|
|
130
|
+
// Filters: what they read is stdin, which is what makes them safe at the
|
|
131
|
+
// end of a pipeline whose first command is. Each is held to its listing
|
|
132
|
+
// shape, a named set of flags, and no token the shell would expand, so
|
|
133
|
+
// none of them can be handed a path: `tail -n 50 log` asks, and
|
|
134
|
+
// `git log | tail -n 50` does not. `grep` keeps its one positional, the
|
|
135
|
+
// pattern, and not `-e`, `-f`, or `-r`, each of which would turn the next
|
|
136
|
+
// positional or the directory into something it reads. No `c` letter in
|
|
137
|
+
// any of them: `-c` is refused in every scope for `git -c`'s sake, so
|
|
138
|
+
// the counting forms are spelled long (`uniq --count`, `wc --bytes`).
|
|
139
|
+
{
|
|
140
|
+
command: 'grep',
|
|
141
|
+
maxPositionals: 1,
|
|
142
|
+
literal: true,
|
|
143
|
+
allowedFlags: [
|
|
144
|
+
'--ignore-case', '--invert-match', '--line-number', '--count', '--word-regexp', '--line-regexp',
|
|
145
|
+
'--only-matching', '--extended-regexp', '--fixed-strings', '--basic-regexp', '--max-count',
|
|
146
|
+
'--after-context', '--before-context', '--context', '--color', '--colour', '--quiet', '--silent',
|
|
147
|
+
'--no-messages',
|
|
148
|
+
'i', 'v', 'n', 'w', 'x', 'o', 'E', 'F', 'G', 'm', 'A', 'B', 'C', 'q', 's',
|
|
149
|
+
],
|
|
150
|
+
flagsWithValue: ['-m', '-A', '-B', '-C', '--max-count', '--after-context', '--before-context', '--context'],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
command: 'head',
|
|
154
|
+
listOnly: true,
|
|
155
|
+
literal: true,
|
|
156
|
+
allowedFlags: ['--lines', '--bytes', '--quiet', '--silent', 'n', 'q', NUMERIC_FLAG],
|
|
157
|
+
flagsWithValue: ['-n', '--lines', '--bytes'],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
command: 'tail',
|
|
161
|
+
listOnly: true,
|
|
162
|
+
literal: true,
|
|
163
|
+
allowedFlags: ['--lines', '--bytes', '--quiet', '--silent', 'n', 'q', 'r', NUMERIC_FLAG],
|
|
164
|
+
flagsWithValue: ['-n', '--lines', '--bytes'],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
command: 'wc',
|
|
168
|
+
listOnly: true,
|
|
169
|
+
literal: true,
|
|
170
|
+
allowedFlags: ['--lines', '--words', '--bytes', '--chars', 'l', 'w', 'm'],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
command: 'sort',
|
|
174
|
+
listOnly: true,
|
|
175
|
+
literal: true,
|
|
176
|
+
// Not `-o`, which writes a file, nor `--compress-program`, which runs one.
|
|
177
|
+
allowedFlags: [
|
|
178
|
+
'--reverse', '--numeric-sort', '--unique', '--ignore-case', '--human-numeric-sort', '--version-sort',
|
|
179
|
+
'--stable', '--key', '--field-separator',
|
|
180
|
+
'r', 'n', 'u', 'f', 'h', 'V', 's', 'k', 't',
|
|
181
|
+
],
|
|
182
|
+
flagsWithValue: ['-k', '-t', '--key', '--field-separator'],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
command: 'uniq',
|
|
186
|
+
listOnly: true,
|
|
187
|
+
literal: true,
|
|
188
|
+
// `uniq in out` writes `out`; listOnly is what refuses that.
|
|
189
|
+
allowedFlags: ['--count', '--repeated', '--unique', '--ignore-case', 'd', 'u', 'i'],
|
|
190
|
+
},
|
|
125
191
|
{ command: 'pwd' },
|
|
126
192
|
{ command: 'whoami' },
|
|
127
193
|
{ command: 'uname' },
|
|
@@ -144,12 +210,31 @@ const CONTROL_OPERATORS = [
|
|
|
144
210
|
{ pattern: /[<>]/, name: 'a redirection (< or >)' },
|
|
145
211
|
{ pattern: /\$\{/, name: 'a parameter expansion (${ })' },
|
|
146
212
|
];
|
|
213
|
+
const EXPANDING = /[*?[\]{}~$\\]/;
|
|
214
|
+
/**
|
|
215
|
+
* The characters a backslash escapes inside double quotes, per POSIX:
|
|
216
|
+
* before anything else it is a literal backslash. Modelled so that a
|
|
217
|
+
* jq filter written `--jq ".[] | select(.a == \"b\")"` is read as the one
|
|
218
|
+
* quoted argument the shell reads, not closed early at its `\"`.
|
|
219
|
+
*/
|
|
220
|
+
const ESCAPABLE_IN_DOUBLE_QUOTES = new Set(['$', '`', '"', '\\', '\n']);
|
|
147
221
|
const tokenize = (command) => {
|
|
148
222
|
const tokens = [];
|
|
223
|
+
const expands = [];
|
|
149
224
|
let current = '';
|
|
150
225
|
let quote;
|
|
151
226
|
let started = false;
|
|
152
|
-
|
|
227
|
+
let expanding = false;
|
|
228
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
229
|
+
const char = command[index];
|
|
230
|
+
if (quote === '"' && char === '\\' && ESCAPABLE_IN_DOUBLE_QUOTES.has(command[index + 1] ?? '')) {
|
|
231
|
+
index += 1;
|
|
232
|
+
// An escaped newline is a line continuation, and gone from the word.
|
|
233
|
+
if (command[index] !== '\n') {
|
|
234
|
+
current += command[index];
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
153
238
|
if (quote) {
|
|
154
239
|
if (char === quote) {
|
|
155
240
|
quote = undefined;
|
|
@@ -167,11 +252,16 @@ const tokenize = (command) => {
|
|
|
167
252
|
if (char === ' ' || char === '\t') {
|
|
168
253
|
if (started) {
|
|
169
254
|
tokens.push(current);
|
|
255
|
+
expands.push(expanding);
|
|
170
256
|
current = '';
|
|
171
257
|
started = false;
|
|
258
|
+
expanding = false;
|
|
172
259
|
}
|
|
173
260
|
continue;
|
|
174
261
|
}
|
|
262
|
+
if (EXPANDING.test(char)) {
|
|
263
|
+
expanding = true;
|
|
264
|
+
}
|
|
175
265
|
current += char;
|
|
176
266
|
started = true;
|
|
177
267
|
}
|
|
@@ -182,33 +272,197 @@ const tokenize = (command) => {
|
|
|
182
272
|
}
|
|
183
273
|
if (started) {
|
|
184
274
|
tokens.push(current);
|
|
275
|
+
expands.push(expanding);
|
|
185
276
|
}
|
|
186
|
-
return tokens;
|
|
277
|
+
return { tokens, expands };
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Split on the pipes the shell would split on: outside quotes, and never
|
|
281
|
+
* `||`, which is a conditional rather than a pipe. Undefined when there is
|
|
282
|
+
* no reading of the string that this parser and `sh` would agree on.
|
|
283
|
+
*/
|
|
284
|
+
const splitPipeline = (command) => {
|
|
285
|
+
const segments = [];
|
|
286
|
+
let current = '';
|
|
287
|
+
let quote;
|
|
288
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
289
|
+
const char = command[index];
|
|
290
|
+
if (quote === '"' && char === '\\' && ESCAPABLE_IN_DOUBLE_QUOTES.has(command[index + 1] ?? '')) {
|
|
291
|
+
current += char + command[index + 1];
|
|
292
|
+
index += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (quote) {
|
|
296
|
+
if (char === quote) {
|
|
297
|
+
quote = undefined;
|
|
298
|
+
}
|
|
299
|
+
current += char;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (char === '"' || char === "'") {
|
|
303
|
+
quote = char;
|
|
304
|
+
}
|
|
305
|
+
else if (char === '|') {
|
|
306
|
+
segments.push(current);
|
|
307
|
+
current = '';
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
current += char;
|
|
311
|
+
}
|
|
312
|
+
if (quote) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
segments.push(current);
|
|
316
|
+
return segments;
|
|
187
317
|
};
|
|
188
318
|
/** Read a command string as far as it can be read safely. */
|
|
189
319
|
export const analyzeCommand = (command) => {
|
|
320
|
+
const segments = splitPipeline(command);
|
|
321
|
+
if (segments && segments.length > 1) {
|
|
322
|
+
return analyzePipeline(command, segments);
|
|
323
|
+
}
|
|
324
|
+
return analyzeSimple(command);
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* A pipeline is judged command by command, and nothing about it is
|
|
328
|
+
* trusted that would not be trusted of its parts. Every other control
|
|
329
|
+
* operator still disqualifies the whole: `||` and `|&` included, since
|
|
330
|
+
* the first is a conditional and the second pipes stderr. A backslash
|
|
331
|
+
* anywhere does too, because `\|` is a literal to `sh` and a split here,
|
|
332
|
+
* and a parser that splits where the shell does not is judging commands
|
|
333
|
+
* nobody runs.
|
|
334
|
+
*/
|
|
335
|
+
const analyzePipeline = (command, segments) => {
|
|
336
|
+
if (command.includes('\\')) {
|
|
337
|
+
return { command, tokens: [], disqualifiedBy: 'it pipes a command containing a backslash' };
|
|
338
|
+
}
|
|
339
|
+
const pipeline = [];
|
|
340
|
+
for (const segment of segments) {
|
|
341
|
+
if (segment.trim().length === 0) {
|
|
342
|
+
return { command, tokens: [], disqualifiedBy: 'it contains an empty pipeline stage (|| or a stray |)' };
|
|
343
|
+
}
|
|
344
|
+
const analysis = analyzeSimple(segment.trim());
|
|
345
|
+
if (analysis.disqualifiedBy) {
|
|
346
|
+
return { command, tokens: [], disqualifiedBy: analysis.disqualifiedBy };
|
|
347
|
+
}
|
|
348
|
+
pipeline.push(analysis);
|
|
349
|
+
}
|
|
350
|
+
return { command, tokens: [], pipeline };
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* The parts of a command the shell reads as syntax. `active` is everything
|
|
354
|
+
* outside single quotes, where `$(`, backticks and `${` still run; `bare` is
|
|
355
|
+
* everything outside any quotes, the only place `(`, `;`, `|` and the rest
|
|
356
|
+
* are operators. A quoted region is replaced by a space rather than dropped,
|
|
357
|
+
* so the text on either side of it can never join into an operator the
|
|
358
|
+
* shell would not see.
|
|
359
|
+
*
|
|
360
|
+
* Undefined when this reading could disagree with `sh`: a backslash outside
|
|
361
|
+
* any quotes, which this scanner does not model, and an unbalanced quote,
|
|
362
|
+
* which has no reading at all. Inside double quotes a backslash is modelled
|
|
363
|
+
* as POSIX has it (`ESCAPABLE_IN_DOUBLE_QUOTES`). The caller then checks the whole
|
|
364
|
+
* string, quotes included, the way it always has.
|
|
365
|
+
*/
|
|
366
|
+
const syntaxOf = (command) => {
|
|
367
|
+
let active = '';
|
|
368
|
+
let bare = '';
|
|
369
|
+
let quote;
|
|
370
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
371
|
+
const char = command[index];
|
|
372
|
+
if (quote === "'") {
|
|
373
|
+
if (char === "'") {
|
|
374
|
+
quote = undefined;
|
|
375
|
+
active += ' ';
|
|
376
|
+
bare += ' ';
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (quote === '"' && char === '\\' && command[index + 1] === '\n') {
|
|
381
|
+
// A line continuation is deleted before the shell parses, joining
|
|
382
|
+
// what was on either side (`"$\<newline>(id)"` runs `id`). Not
|
|
383
|
+
// modelled; the whole string is checked instead.
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
if (quote === '"' && char === '\\') {
|
|
387
|
+
// Escaping one of the characters that mean something here makes it
|
|
388
|
+
// text, so it is kept out of `active`, where `$(` and backticks are
|
|
389
|
+
// looked for. Before anything else the backslash is itself text.
|
|
390
|
+
if (ESCAPABLE_IN_DOUBLE_QUOTES.has(command[index + 1] ?? '')) {
|
|
391
|
+
index += 1;
|
|
392
|
+
active += ' ';
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
// Kept, so it still stands between what it separates: `"$\{x}"`
|
|
396
|
+
// is no parameter expansion to the shell, and is none here.
|
|
397
|
+
active += char;
|
|
398
|
+
}
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (char === '\\') {
|
|
402
|
+
// Outside quotes a backslash makes any character text, an operator
|
|
403
|
+
// included. Not modelled; the whole string is checked instead.
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
if (quote === '"') {
|
|
407
|
+
if (char === '"') {
|
|
408
|
+
quote = undefined;
|
|
409
|
+
bare += ' ';
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
active += char;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (char === '"' || char === "'") {
|
|
417
|
+
quote = char;
|
|
418
|
+
active += ' ';
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (char === '#') {
|
|
422
|
+
// An unquoted `#` can start a comment, inside which `sh` ignores
|
|
423
|
+
// quotes up to the newline — so a quote there would put this scanner
|
|
424
|
+
// in quoted mode over text the shell runs. Not modelled; checked
|
|
425
|
+
// whole instead.
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
active += char;
|
|
429
|
+
bare += char;
|
|
430
|
+
}
|
|
431
|
+
return quote ? undefined : { active, bare };
|
|
432
|
+
};
|
|
433
|
+
/** Operators the shell still honors inside double quotes. */
|
|
434
|
+
const EXPANDS_IN_DOUBLE_QUOTES = new Set(['command substitution ($( ))', 'command substitution (backticks)', 'a parameter expansion (${ })']);
|
|
435
|
+
const analyzeSimple = (command) => {
|
|
436
|
+
// A `(` in a commit message is text, not a subshell: operators are looked
|
|
437
|
+
// for where the shell would read them, unless the quoting is too subtle
|
|
438
|
+
// to be sure of, in which case every character counts.
|
|
439
|
+
const syntax = syntaxOf(command);
|
|
190
440
|
for (const operator of CONTROL_OPERATORS) {
|
|
191
|
-
|
|
441
|
+
const where = syntax === undefined
|
|
442
|
+
? command
|
|
443
|
+
: EXPANDS_IN_DOUBLE_QUOTES.has(operator.name) ? syntax.active : syntax.bare;
|
|
444
|
+
if (operator.pattern.test(where)) {
|
|
192
445
|
return { command, tokens: [], disqualifiedBy: `it contains ${operator.name}` };
|
|
193
446
|
}
|
|
194
447
|
}
|
|
195
|
-
const
|
|
196
|
-
if (!
|
|
448
|
+
const read = tokenize(command);
|
|
449
|
+
if (!read || read.tokens.length === 0) {
|
|
197
450
|
return { command, tokens: [], disqualifiedBy: 'it could not be read as a command' };
|
|
198
451
|
}
|
|
452
|
+
const { tokens, expands } = read;
|
|
199
453
|
const base = tokens[0];
|
|
200
454
|
if (base.includes('/') || base.includes('\\')) {
|
|
201
455
|
// A scope names a command, and `/usr/bin/git` is not that name. Refusing
|
|
202
456
|
// beats resolving: `./git` in a cloned repository is a different program
|
|
203
457
|
// with the same basename.
|
|
204
|
-
return { command, tokens, disqualifiedBy: 'it names a path rather than a command' };
|
|
458
|
+
return { command, tokens, expands, disqualifiedBy: 'it names a path rather than a command' };
|
|
205
459
|
}
|
|
206
460
|
if (base.startsWith('-') || base.includes('=')) {
|
|
207
461
|
// `FOO=bar cmd` is an environment assignment, which is the shell's job
|
|
208
462
|
// and not this parser's.
|
|
209
|
-
return { command, tokens, disqualifiedBy: 'it does not start with a command' };
|
|
463
|
+
return { command, tokens, expands, disqualifiedBy: 'it does not start with a command' };
|
|
210
464
|
}
|
|
211
|
-
return { command, base, tokens };
|
|
465
|
+
return { command, base, tokens, expands };
|
|
212
466
|
};
|
|
213
467
|
const flagsOf = (token) => {
|
|
214
468
|
if (token.startsWith('--')) {
|
|
@@ -243,8 +497,13 @@ const allowsFlag = (allowed, token) => {
|
|
|
243
497
|
return allowed.includes(long);
|
|
244
498
|
}
|
|
245
499
|
const letters = shorts.filter((letter) => !/[0-9]/.test(letter));
|
|
246
|
-
|
|
500
|
+
if (letters.length === 0) {
|
|
501
|
+
return shorts.length > 0 && allowed.includes(NUMERIC_FLAG);
|
|
502
|
+
}
|
|
503
|
+
return letters.every((letter) => allowed.includes(letter));
|
|
247
504
|
};
|
|
505
|
+
/** Git subcommands whose own `-c` creates or reuses, never configures. */
|
|
506
|
+
const GIT_SUBCOMMANDS_WITH_PLAIN_C = new Set(['switch', 'commit']);
|
|
248
507
|
/** Whether an invocation falls inside one scope. */
|
|
249
508
|
export const matchesScope = (analysis, scope) => {
|
|
250
509
|
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
@@ -264,12 +523,39 @@ export const matchesScope = (analysis, scope) => {
|
|
|
264
523
|
}
|
|
265
524
|
}
|
|
266
525
|
const denied = [...ALWAYS_DENIED_FLAGS, ...(scope.deniedFlags ?? [])];
|
|
526
|
+
// `-c` is refused everywhere for `git -c`, which sets config before the
|
|
527
|
+
// subcommand. After a subcommand it is that subcommand's own flag:
|
|
528
|
+
// `git switch -c` creates a branch, `git commit -c` reuses a message.
|
|
529
|
+
// Only the tail past the subcommand is relieved; the subcommand itself is
|
|
530
|
+
// a literal required argument, so `-c` cannot be it.
|
|
531
|
+
// Only for subcommands whose `-c` is known not to set config: `clone -c`
|
|
532
|
+
// does exactly what `git -c` does (`core.sshCommand=…`).
|
|
533
|
+
// Past every leading `-C <repo>` pair: git applies each in turn.
|
|
534
|
+
let subcommandAt = 0;
|
|
535
|
+
while (scope.command === 'git' && required[subcommandAt] === '-C' && required[subcommandAt + 1] !== undefined) {
|
|
536
|
+
subcommandAt += 2;
|
|
537
|
+
}
|
|
538
|
+
// Only the `-c` the shared list contributes; a scope that names `-c` in
|
|
539
|
+
// its own `deniedFlags` still means it.
|
|
540
|
+
const deniedInTail = scope.command === 'git' && GIT_SUBCOMMANDS_WITH_PLAIN_C.has(required[subcommandAt] ?? '')
|
|
541
|
+
? [...ALWAYS_DENIED_FLAGS.filter((flag) => flag !== '-c'), ...(scope.deniedFlags ?? [])]
|
|
542
|
+
: denied;
|
|
267
543
|
// A required token can itself be a flag or a refspec — an exact scope
|
|
268
544
|
// carries the whole approved command — and a whitelist file is
|
|
269
545
|
// hand-editable, so the prefix is held to the same rules as the rest.
|
|
270
|
-
for (const token of args.slice(0, required.length)) {
|
|
546
|
+
for (const [index, token] of args.slice(0, required.length).entries()) {
|
|
547
|
+
// A leading `-C <repo>` in a git scope is git's own directory flag, put
|
|
548
|
+
// there by `normalizeCommandScope`; the subcommand's refusal of `-C`
|
|
549
|
+
// (`git branch -C` copies) is about the tokens after the subcommand.
|
|
550
|
+
// Its operand is a path, not an argument of the subcommand's, so the
|
|
551
|
+
// subcommand's denied arguments and git's refspec rule do not apply to
|
|
552
|
+
// it either: `git -C add remote` is the `remote` subcommand in `add`.
|
|
553
|
+
if (scope.command === 'git' && subcommandAt > 0 && required.length > subcommandAt && index < subcommandAt) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
271
556
|
if (token.startsWith('-')) {
|
|
272
|
-
|
|
557
|
+
// Past the subcommand, the same relief the tail gets (`['switch', '-c']`).
|
|
558
|
+
if (deniesFlag(index > subcommandAt ? deniedInTail : denied, token)) {
|
|
273
559
|
return false;
|
|
274
560
|
}
|
|
275
561
|
continue;
|
|
@@ -281,20 +567,59 @@ export const matchesScope = (analysis, scope) => {
|
|
|
281
567
|
return false;
|
|
282
568
|
}
|
|
283
569
|
}
|
|
570
|
+
if (scope.literal && analysis.expands?.some((expands) => expands)) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
284
573
|
const rest = args.slice(required.length);
|
|
285
|
-
|
|
574
|
+
let positionals = 0;
|
|
575
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
576
|
+
const token = rest[index];
|
|
577
|
+
// After `--` a dash token is an operand to a program that honors it and
|
|
578
|
+
// a flag to one that doesn't, and the two can't both be checked as
|
|
579
|
+
// written: it asks.
|
|
580
|
+
// The required prefix counts: a scope may itself end in `--`.
|
|
581
|
+
if (token.startsWith('-') && args.slice(0, required.length + index).includes('--')) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
286
584
|
if (token.startsWith('-')) {
|
|
287
|
-
|
|
585
|
+
// `-cfix` is `-c fix`: the rest is the branch (or commit) it takes,
|
|
586
|
+
// not more flags, unless the scope itself denies `-c`.
|
|
587
|
+
// A short bundle with `c` in it (`-cfix`, `-qvcHEAD`): the letters
|
|
588
|
+
// before `c` are flags, and everything after it is `-c`'s value.
|
|
589
|
+
const at = !token.startsWith('--') ? token.indexOf('c', 1) : -1;
|
|
590
|
+
if (deniedInTail !== denied && at > 0 && token.length > at + 1) {
|
|
591
|
+
const before = [...token.slice(1, at)].map((letter) => `-${letter}`);
|
|
592
|
+
for (const flag of [...before, '-c']) {
|
|
593
|
+
// The same checks each flag meets on its own, minus reading the
|
|
594
|
+
// value as more flags; `-c` against the scope's own denials only.
|
|
595
|
+
const refusals = flag === '-c' ? scope.deniedFlags ?? [] : deniedInTail;
|
|
596
|
+
if (deniesFlag(refusals, flag) || (scope.allowedFlags && !allowsFlag(scope.allowedFlags, flag))) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (deniesFlag(deniedInTail, token)) {
|
|
288
603
|
return false;
|
|
289
604
|
}
|
|
290
605
|
if (scope.allowedFlags && !allowsFlag(scope.allowedFlags, token)) {
|
|
291
606
|
return false;
|
|
292
607
|
}
|
|
608
|
+
if (scope.flagsWithValue?.includes(token)) {
|
|
609
|
+
if (index + 1 >= rest.length) {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
index += 1;
|
|
613
|
+
}
|
|
293
614
|
continue;
|
|
294
615
|
}
|
|
295
616
|
if (scope.listOnly) {
|
|
296
617
|
return false;
|
|
297
618
|
}
|
|
619
|
+
positionals += 1;
|
|
620
|
+
if (scope.maxPositionals !== undefined && positionals > scope.maxPositionals) {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
298
623
|
if (scope.deniedArgs?.includes(token)) {
|
|
299
624
|
return false;
|
|
300
625
|
}
|
|
@@ -304,6 +629,22 @@ export const matchesScope = (analysis, scope) => {
|
|
|
304
629
|
}
|
|
305
630
|
return true;
|
|
306
631
|
};
|
|
632
|
+
/**
|
|
633
|
+
* The scopes covering an invocation, one per command of a pipeline and one
|
|
634
|
+
* for a plain command, or undefined unless every command is covered.
|
|
635
|
+
*/
|
|
636
|
+
export const findCoveringScopes = (analysis, scopes) => {
|
|
637
|
+
const commands = analysis.pipeline ?? [analysis];
|
|
638
|
+
const covering = [];
|
|
639
|
+
for (const command of commands) {
|
|
640
|
+
const scope = findMatchingScope(command, scopes);
|
|
641
|
+
if (!scope) {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
covering.push(scope);
|
|
645
|
+
}
|
|
646
|
+
return covering;
|
|
647
|
+
};
|
|
307
648
|
/** The first scope covering this invocation, if any covers it. */
|
|
308
649
|
export const findMatchingScope = (analysis, scopes) => scopes.find((scope) => matchesScope(analysis, scope));
|
|
309
650
|
/**
|
|
@@ -346,6 +687,23 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
346
687
|
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
347
688
|
return undefined;
|
|
348
689
|
}
|
|
690
|
+
// `git -C <repo> <subcommand> …` is `git <subcommand> …` run in <repo>,
|
|
691
|
+
// and an agent with worktrees spells nearly every git call this way. The
|
|
692
|
+
// repository is kept in the scope, literally, so a grant for one worktree
|
|
693
|
+
// says nothing about another; past it the subcommand is judged as it
|
|
694
|
+
// would be without -C. Only the leading position: anywhere else -C sits
|
|
695
|
+
// among flags of unknown arity, and the exact-command rule below applies.
|
|
696
|
+
if (analysis.base === 'git' && analysis.tokens[1] === '-C' && analysis.tokens.length > 3) {
|
|
697
|
+
const repo = analysis.tokens[2];
|
|
698
|
+
if (repo.length > 0 && !repo.startsWith('-') && !analysis.expands?.[2] && !/[*?[\]{}~$\\#]/.test(repo)) {
|
|
699
|
+
const inner = normalizeCommandScope({
|
|
700
|
+
...analysis,
|
|
701
|
+
tokens: ['git', ...analysis.tokens.slice(3)],
|
|
702
|
+
...(analysis.expands ? { expands: [analysis.expands[0] ?? false, ...analysis.expands.slice(3)] } : {}),
|
|
703
|
+
});
|
|
704
|
+
return inner === undefined ? undefined : { ...inner, args: ['-C', repo, ...(inner.args ?? [])] };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
349
707
|
const tokens = analysis.tokens.slice(1);
|
|
350
708
|
const firstIndex = tokens.findIndex((token) => !token.startsWith('-'));
|
|
351
709
|
const first = firstIndex === -1 ? undefined : tokens[firstIndex];
|
|
@@ -412,6 +770,14 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
412
770
|
...(refspecs ? { denyRefspecForms: true } : {}),
|
|
413
771
|
};
|
|
414
772
|
}
|
|
773
|
+
// The stored argument is what the scope is named for and which safe
|
|
774
|
+
// scope's constraints it inherits, so it must be the argument the shell
|
|
775
|
+
// passes: `git \branch --list` runs `git branch --list`, and a scope
|
|
776
|
+
// stored as `git \branch` would inherit nothing of `branch`'s list-only
|
|
777
|
+
// rule while matching `git \branch release`.
|
|
778
|
+
if (first !== undefined && (analysis.expands?.[firstIndex + 1] || /[*?[\]{}~$\\#]/.test(first))) {
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
415
781
|
const sameScope = SAFE_COMMAND_SCOPES
|
|
416
782
|
.filter((scope) => scope.command === analysis.base && (scope.args ?? []).join(' ') === (first ?? ''));
|
|
417
783
|
const inherited = sameScope.flatMap((scope) => scope.deniedFlags ?? []);
|
|
@@ -439,6 +805,105 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
439
805
|
...(refspecs ? { denyRefspecForms: true } : {}),
|
|
440
806
|
};
|
|
441
807
|
};
|
|
808
|
+
/**
|
|
809
|
+
* The scope an operator declares in config (`approvals.commands`): a
|
|
810
|
+
* command and, optionally, the subcommands it is limited to — `agentboard`,
|
|
811
|
+
* `pnpm test`, `gh pr`. Whatever follows the prefix may vary, the way a
|
|
812
|
+
* remembered scope's arguments do, and the same things stay refused: the
|
|
813
|
+
* destructive flags, whatever the built-in list refuses for that command,
|
|
814
|
+
* and git's refspec deletes.
|
|
815
|
+
*
|
|
816
|
+
* Only words. A flag, an operator, or a token the shell would expand has no
|
|
817
|
+
* meaning as a prefix, and guessing one would be a grant nobody wrote, so
|
|
818
|
+
* the entry is refused with the reason instead.
|
|
819
|
+
*/
|
|
820
|
+
export const commandScopeFromPrefix = (prefix) => {
|
|
821
|
+
// Plain words only, so the entry as written is the scope as matched, and
|
|
822
|
+
// every listing and revoke can compare it word for word.
|
|
823
|
+
if (/['"]/.test(prefix)) {
|
|
824
|
+
return { reason: 'it contains quotes; write the command as plain words' };
|
|
825
|
+
}
|
|
826
|
+
const analysis = analyzeCommand(prefix.trim());
|
|
827
|
+
if (analysis.pipeline) {
|
|
828
|
+
return { reason: 'it is a pipeline; list each command on its own' };
|
|
829
|
+
}
|
|
830
|
+
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
831
|
+
return { reason: analysis.disqualifiedBy ?? 'it could not be read as a command' };
|
|
832
|
+
}
|
|
833
|
+
const args = analysis.tokens.slice(1);
|
|
834
|
+
if (args.some((token) => token.startsWith('-'))) {
|
|
835
|
+
return { reason: 'it names a flag; list the command and its subcommands only' };
|
|
836
|
+
}
|
|
837
|
+
// A path is a program or a file, not a subcommand: `python scripts/x.py`
|
|
838
|
+
// would let any tail follow a script nobody reviewed by name.
|
|
839
|
+
if (args.some((token) => token.includes('/') || token.startsWith('.'))) {
|
|
840
|
+
return { reason: 'it names a path; list the command and its subcommands only' };
|
|
841
|
+
}
|
|
842
|
+
if (analysis.expands?.some((expands) => expands) || analysis.tokens.some((token) => /[*?[\]{}~$\\#]/.test(token))) {
|
|
843
|
+
return { reason: 'it contains something the shell would expand' };
|
|
844
|
+
}
|
|
845
|
+
const forBase = SAFE_COMMAND_SCOPES.filter((scope) => scope.command === analysis.base);
|
|
846
|
+
const declared = args.join(' ');
|
|
847
|
+
// A prefix shorter than a subcommand the built-in list limits would cover
|
|
848
|
+
// that subcommand's mutating forms: `git` would run `git branch release`,
|
|
849
|
+
// which the list's own `git branch` scope exists to refuse.
|
|
850
|
+
const narrower = forBase.find((scope) => {
|
|
851
|
+
const sub = scope.args ?? [];
|
|
852
|
+
return sub.length > args.length
|
|
853
|
+
&& args.every((token, index) => sub[index] === token)
|
|
854
|
+
&& (scope.listOnly || scope.allowedFlags || scope.maxPositionals !== undefined);
|
|
855
|
+
});
|
|
856
|
+
if (narrower) {
|
|
857
|
+
return { reason: `the built-in list limits \`${describeCommandScope(narrower)}\`; list the subcommands it may run instead` };
|
|
858
|
+
}
|
|
859
|
+
// Nor longer than a limited built-in scope: `grep fix` names grep's
|
|
860
|
+
// pattern, and as a prefix it would let any file follow it, which the
|
|
861
|
+
// built-in `grep` exists to refuse. The limits are about the arguments a
|
|
862
|
+
// prefix fixes in place, so there is no adjusting them; the entry is
|
|
863
|
+
// refused and the built-in scope already covers what it was safe for.
|
|
864
|
+
const extended = forBase.find((scope) => {
|
|
865
|
+
const sub = scope.args ?? [];
|
|
866
|
+
return sub.length < args.length
|
|
867
|
+
&& sub.every((token, index) => args[index] === token)
|
|
868
|
+
&& (scope.listOnly || scope.allowedFlags || scope.maxPositionals !== undefined || scope.literal || (scope.flagsWithValue ?? []).length > 0);
|
|
869
|
+
});
|
|
870
|
+
if (extended) {
|
|
871
|
+
return { reason: `it extends \`${describeCommandScope(extended)}\`, which the built-in list already limits; it runs unattended within those limits without an entry` };
|
|
872
|
+
}
|
|
873
|
+
// And the same prefix as a built-in scope keeps every limit it draws —
|
|
874
|
+
// list-only, the named flags, the positional count — never just the
|
|
875
|
+
// refusals: `git branch` must still not create a branch.
|
|
876
|
+
const same = forBase.filter((scope) => (scope.args ?? []).join(' ') === declared);
|
|
877
|
+
const allowedFlags = same.length > 0 && same.every((scope) => scope.allowedFlags)
|
|
878
|
+
? [...new Set(same.flatMap((scope) => scope.allowedFlags ?? []))]
|
|
879
|
+
: undefined;
|
|
880
|
+
const flagsWithValue = [...new Set(same.flatMap((scope) => scope.flagsWithValue ?? []))];
|
|
881
|
+
const positionals = same.map((scope) => scope.maxPositionals).filter((count) => count !== undefined);
|
|
882
|
+
// Refusals come only from built-in scopes on the same path as this one:
|
|
883
|
+
// `git remote` refuses `add` as its argument, which says nothing about
|
|
884
|
+
// `git add`, and copying it would refuse the entry's own subcommand.
|
|
885
|
+
const related = forBase.filter((scope) => {
|
|
886
|
+
const sub = scope.args ?? [];
|
|
887
|
+
const shorter = sub.length <= args.length ? sub : args;
|
|
888
|
+
const longer = sub.length <= args.length ? args : sub;
|
|
889
|
+
return shorter.every((token, index) => longer[index] === token);
|
|
890
|
+
});
|
|
891
|
+
const deniedArgs = related.flatMap((scope) => scope.deniedArgs ?? []);
|
|
892
|
+
return {
|
|
893
|
+
scope: {
|
|
894
|
+
command: analysis.base,
|
|
895
|
+
...(args.length > 0 ? { args } : {}),
|
|
896
|
+
deniedFlags: [...new Set([...DESTRUCTIVE_FLAGS, ...related.flatMap((scope) => scope.deniedFlags ?? [])])],
|
|
897
|
+
...(deniedArgs.length > 0 ? { deniedArgs: [...new Set(deniedArgs)] } : {}),
|
|
898
|
+
...(same.some((scope) => scope.listOnly) ? { listOnly: true } : {}),
|
|
899
|
+
...(allowedFlags ? { allowedFlags } : {}),
|
|
900
|
+
...(flagsWithValue.length > 0 ? { flagsWithValue } : {}),
|
|
901
|
+
...(positionals.length > 0 ? { maxPositionals: Math.min(...positionals) } : {}),
|
|
902
|
+
...(same.some((scope) => scope.literal) ? { literal: true } : {}),
|
|
903
|
+
...(analysis.base === 'git' ? { denyRefspecForms: true } : {}),
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
};
|
|
442
907
|
/** One line an operator can read in a log or a whitelist listing. */
|
|
443
908
|
export const describeCommandScope = (scope) => [scope.command, ...(scope.args ?? [])].join(' ');
|
|
444
909
|
/** Whether two scopes permit the same thing, so a whitelist does not grow duplicates. */
|
|
@@ -451,6 +916,9 @@ const normalizeForCompare = (scope) => ({
|
|
|
451
916
|
denyRefspecForms: scope.denyRefspecForms ?? false,
|
|
452
917
|
listOnly: scope.listOnly ?? false,
|
|
453
918
|
allowedFlags: [...(scope.allowedFlags ?? [])].sort(),
|
|
919
|
+
maxPositionals: scope.maxPositionals ?? null,
|
|
920
|
+
flagsWithValue: [...(scope.flagsWithValue ?? [])].sort(),
|
|
921
|
+
literal: scope.literal ?? false,
|
|
454
922
|
});
|
|
455
923
|
/** Read one scope out of a whitelist file, or refuse it. */
|
|
456
924
|
export const parseCommandScope = (raw) => {
|
|
@@ -466,6 +934,11 @@ export const parseCommandScope = (raw) => {
|
|
|
466
934
|
const deniedFlags = strings(source.deniedFlags);
|
|
467
935
|
const deniedArgs = strings(source.deniedArgs);
|
|
468
936
|
const allowedFlags = strings(source.allowedFlags);
|
|
937
|
+
const flagsWithValue = strings(source.flagsWithValue);
|
|
938
|
+
const maxPositionals = typeof source.maxPositionals === 'number'
|
|
939
|
+
&& Number.isInteger(source.maxPositionals) && source.maxPositionals >= 0
|
|
940
|
+
? source.maxPositionals
|
|
941
|
+
: undefined;
|
|
469
942
|
return {
|
|
470
943
|
command: source.command,
|
|
471
944
|
...(args ? { args } : {}),
|
|
@@ -474,6 +947,9 @@ export const parseCommandScope = (raw) => {
|
|
|
474
947
|
...(source.denyRefspecForms === true ? { denyRefspecForms: true } : {}),
|
|
475
948
|
...(source.listOnly === true ? { listOnly: true } : {}),
|
|
476
949
|
...(allowedFlags ? { allowedFlags } : {}),
|
|
950
|
+
...(maxPositionals !== undefined ? { maxPositionals } : {}),
|
|
951
|
+
...(flagsWithValue ? { flagsWithValue } : {}),
|
|
952
|
+
...(source.literal === true ? { literal: true } : {}),
|
|
477
953
|
};
|
|
478
954
|
};
|
|
479
955
|
//# sourceMappingURL=commands.js.map
|