@stratusagent/permissions 0.11.8 → 0.11.9
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 +441 -12
- 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 +750 -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,11 +210,14 @@ const CONTROL_OPERATORS = [
|
|
|
144
210
|
{ pattern: /[<>]/, name: 'a redirection (< or >)' },
|
|
145
211
|
{ pattern: /\$\{/, name: 'a parameter expansion (${ })' },
|
|
146
212
|
];
|
|
213
|
+
const EXPANDING = /[*?[\]{}~$\\]/;
|
|
147
214
|
const tokenize = (command) => {
|
|
148
215
|
const tokens = [];
|
|
216
|
+
const expands = [];
|
|
149
217
|
let current = '';
|
|
150
218
|
let quote;
|
|
151
219
|
let started = false;
|
|
220
|
+
let expanding = false;
|
|
152
221
|
for (const char of command) {
|
|
153
222
|
if (quote) {
|
|
154
223
|
if (char === quote) {
|
|
@@ -167,11 +236,16 @@ const tokenize = (command) => {
|
|
|
167
236
|
if (char === ' ' || char === '\t') {
|
|
168
237
|
if (started) {
|
|
169
238
|
tokens.push(current);
|
|
239
|
+
expands.push(expanding);
|
|
170
240
|
current = '';
|
|
171
241
|
started = false;
|
|
242
|
+
expanding = false;
|
|
172
243
|
}
|
|
173
244
|
continue;
|
|
174
245
|
}
|
|
246
|
+
if (EXPANDING.test(char)) {
|
|
247
|
+
expanding = true;
|
|
248
|
+
}
|
|
175
249
|
current += char;
|
|
176
250
|
started = true;
|
|
177
251
|
}
|
|
@@ -182,33 +256,166 @@ const tokenize = (command) => {
|
|
|
182
256
|
}
|
|
183
257
|
if (started) {
|
|
184
258
|
tokens.push(current);
|
|
259
|
+
expands.push(expanding);
|
|
185
260
|
}
|
|
186
|
-
return tokens;
|
|
261
|
+
return { tokens, expands };
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Split on the pipes the shell would split on: outside quotes, and never
|
|
265
|
+
* `||`, which is a conditional rather than a pipe. Undefined when there is
|
|
266
|
+
* no reading of the string that this parser and `sh` would agree on.
|
|
267
|
+
*/
|
|
268
|
+
const splitPipeline = (command) => {
|
|
269
|
+
const segments = [];
|
|
270
|
+
let current = '';
|
|
271
|
+
let quote;
|
|
272
|
+
for (const char of command) {
|
|
273
|
+
if (quote) {
|
|
274
|
+
if (char === quote) {
|
|
275
|
+
quote = undefined;
|
|
276
|
+
}
|
|
277
|
+
current += char;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (char === '"' || char === "'") {
|
|
281
|
+
quote = char;
|
|
282
|
+
}
|
|
283
|
+
else if (char === '|') {
|
|
284
|
+
segments.push(current);
|
|
285
|
+
current = '';
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
current += char;
|
|
289
|
+
}
|
|
290
|
+
if (quote) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
segments.push(current);
|
|
294
|
+
return segments;
|
|
187
295
|
};
|
|
188
296
|
/** Read a command string as far as it can be read safely. */
|
|
189
297
|
export const analyzeCommand = (command) => {
|
|
298
|
+
const segments = splitPipeline(command);
|
|
299
|
+
if (segments && segments.length > 1) {
|
|
300
|
+
return analyzePipeline(command, segments);
|
|
301
|
+
}
|
|
302
|
+
return analyzeSimple(command);
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* A pipeline is judged command by command, and nothing about it is
|
|
306
|
+
* trusted that would not be trusted of its parts. Every other control
|
|
307
|
+
* operator still disqualifies the whole: `||` and `|&` included, since
|
|
308
|
+
* the first is a conditional and the second pipes stderr. A backslash
|
|
309
|
+
* anywhere does too, because `\|` is a literal to `sh` and a split here,
|
|
310
|
+
* and a parser that splits where the shell does not is judging commands
|
|
311
|
+
* nobody runs.
|
|
312
|
+
*/
|
|
313
|
+
const analyzePipeline = (command, segments) => {
|
|
314
|
+
if (command.includes('\\')) {
|
|
315
|
+
return { command, tokens: [], disqualifiedBy: 'it pipes a command containing a backslash' };
|
|
316
|
+
}
|
|
317
|
+
const pipeline = [];
|
|
318
|
+
for (const segment of segments) {
|
|
319
|
+
if (segment.trim().length === 0) {
|
|
320
|
+
return { command, tokens: [], disqualifiedBy: 'it contains an empty pipeline stage (|| or a stray |)' };
|
|
321
|
+
}
|
|
322
|
+
const analysis = analyzeSimple(segment.trim());
|
|
323
|
+
if (analysis.disqualifiedBy) {
|
|
324
|
+
return { command, tokens: [], disqualifiedBy: analysis.disqualifiedBy };
|
|
325
|
+
}
|
|
326
|
+
pipeline.push(analysis);
|
|
327
|
+
}
|
|
328
|
+
return { command, tokens: [], pipeline };
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* The parts of a command the shell reads as syntax. `active` is everything
|
|
332
|
+
* outside single quotes, where `$(`, backticks and `${` still run; `bare` is
|
|
333
|
+
* everything outside any quotes, the only place `(`, `;`, `|` and the rest
|
|
334
|
+
* are operators. A quoted region is replaced by a space rather than dropped,
|
|
335
|
+
* so the text on either side of it can never join into an operator the
|
|
336
|
+
* shell would not see.
|
|
337
|
+
*
|
|
338
|
+
* Undefined when this reading could disagree with `sh`: a backslash outside
|
|
339
|
+
* single quotes escapes a quote, which this scanner does not model, and an
|
|
340
|
+
* unbalanced quote has no reading at all. The caller then checks the whole
|
|
341
|
+
* string, quotes included, the way it always has.
|
|
342
|
+
*/
|
|
343
|
+
const syntaxOf = (command) => {
|
|
344
|
+
let active = '';
|
|
345
|
+
let bare = '';
|
|
346
|
+
let quote;
|
|
347
|
+
for (const char of command) {
|
|
348
|
+
if (quote === "'") {
|
|
349
|
+
if (char === "'") {
|
|
350
|
+
quote = undefined;
|
|
351
|
+
active += ' ';
|
|
352
|
+
bare += ' ';
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (char === '\\') {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
if (quote === '"') {
|
|
360
|
+
if (char === '"') {
|
|
361
|
+
quote = undefined;
|
|
362
|
+
bare += ' ';
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
active += char;
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (char === '"' || char === "'") {
|
|
370
|
+
quote = char;
|
|
371
|
+
active += ' ';
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (char === '#') {
|
|
375
|
+
// An unquoted `#` can start a comment, inside which `sh` ignores
|
|
376
|
+
// quotes up to the newline — so a quote there would put this scanner
|
|
377
|
+
// in quoted mode over text the shell runs. Not modelled; checked
|
|
378
|
+
// whole instead.
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
active += char;
|
|
382
|
+
bare += char;
|
|
383
|
+
}
|
|
384
|
+
return quote ? undefined : { active, bare };
|
|
385
|
+
};
|
|
386
|
+
/** Operators the shell still honors inside double quotes. */
|
|
387
|
+
const EXPANDS_IN_DOUBLE_QUOTES = new Set(['command substitution ($( ))', 'command substitution (backticks)', 'a parameter expansion (${ })']);
|
|
388
|
+
const analyzeSimple = (command) => {
|
|
389
|
+
// A `(` in a commit message is text, not a subshell: operators are looked
|
|
390
|
+
// for where the shell would read them, unless the quoting is too subtle
|
|
391
|
+
// to be sure of, in which case every character counts.
|
|
392
|
+
const syntax = syntaxOf(command);
|
|
190
393
|
for (const operator of CONTROL_OPERATORS) {
|
|
191
|
-
|
|
394
|
+
const where = syntax === undefined
|
|
395
|
+
? command
|
|
396
|
+
: EXPANDS_IN_DOUBLE_QUOTES.has(operator.name) ? syntax.active : syntax.bare;
|
|
397
|
+
if (operator.pattern.test(where)) {
|
|
192
398
|
return { command, tokens: [], disqualifiedBy: `it contains ${operator.name}` };
|
|
193
399
|
}
|
|
194
400
|
}
|
|
195
|
-
const
|
|
196
|
-
if (!
|
|
401
|
+
const read = tokenize(command);
|
|
402
|
+
if (!read || read.tokens.length === 0) {
|
|
197
403
|
return { command, tokens: [], disqualifiedBy: 'it could not be read as a command' };
|
|
198
404
|
}
|
|
405
|
+
const { tokens, expands } = read;
|
|
199
406
|
const base = tokens[0];
|
|
200
407
|
if (base.includes('/') || base.includes('\\')) {
|
|
201
408
|
// A scope names a command, and `/usr/bin/git` is not that name. Refusing
|
|
202
409
|
// beats resolving: `./git` in a cloned repository is a different program
|
|
203
410
|
// with the same basename.
|
|
204
|
-
return { command, tokens, disqualifiedBy: 'it names a path rather than a command' };
|
|
411
|
+
return { command, tokens, expands, disqualifiedBy: 'it names a path rather than a command' };
|
|
205
412
|
}
|
|
206
413
|
if (base.startsWith('-') || base.includes('=')) {
|
|
207
414
|
// `FOO=bar cmd` is an environment assignment, which is the shell's job
|
|
208
415
|
// and not this parser's.
|
|
209
|
-
return { command, tokens, disqualifiedBy: 'it does not start with a command' };
|
|
416
|
+
return { command, tokens, expands, disqualifiedBy: 'it does not start with a command' };
|
|
210
417
|
}
|
|
211
|
-
return { command, base, tokens };
|
|
418
|
+
return { command, base, tokens, expands };
|
|
212
419
|
};
|
|
213
420
|
const flagsOf = (token) => {
|
|
214
421
|
if (token.startsWith('--')) {
|
|
@@ -243,8 +450,13 @@ const allowsFlag = (allowed, token) => {
|
|
|
243
450
|
return allowed.includes(long);
|
|
244
451
|
}
|
|
245
452
|
const letters = shorts.filter((letter) => !/[0-9]/.test(letter));
|
|
246
|
-
|
|
453
|
+
if (letters.length === 0) {
|
|
454
|
+
return shorts.length > 0 && allowed.includes(NUMERIC_FLAG);
|
|
455
|
+
}
|
|
456
|
+
return letters.every((letter) => allowed.includes(letter));
|
|
247
457
|
};
|
|
458
|
+
/** Git subcommands whose own `-c` creates or reuses, never configures. */
|
|
459
|
+
const GIT_SUBCOMMANDS_WITH_PLAIN_C = new Set(['switch', 'commit']);
|
|
248
460
|
/** Whether an invocation falls inside one scope. */
|
|
249
461
|
export const matchesScope = (analysis, scope) => {
|
|
250
462
|
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
@@ -264,12 +476,39 @@ export const matchesScope = (analysis, scope) => {
|
|
|
264
476
|
}
|
|
265
477
|
}
|
|
266
478
|
const denied = [...ALWAYS_DENIED_FLAGS, ...(scope.deniedFlags ?? [])];
|
|
479
|
+
// `-c` is refused everywhere for `git -c`, which sets config before the
|
|
480
|
+
// subcommand. After a subcommand it is that subcommand's own flag:
|
|
481
|
+
// `git switch -c` creates a branch, `git commit -c` reuses a message.
|
|
482
|
+
// Only the tail past the subcommand is relieved; the subcommand itself is
|
|
483
|
+
// a literal required argument, so `-c` cannot be it.
|
|
484
|
+
// Only for subcommands whose `-c` is known not to set config: `clone -c`
|
|
485
|
+
// does exactly what `git -c` does (`core.sshCommand=…`).
|
|
486
|
+
// Past every leading `-C <repo>` pair: git applies each in turn.
|
|
487
|
+
let subcommandAt = 0;
|
|
488
|
+
while (scope.command === 'git' && required[subcommandAt] === '-C' && required[subcommandAt + 1] !== undefined) {
|
|
489
|
+
subcommandAt += 2;
|
|
490
|
+
}
|
|
491
|
+
// Only the `-c` the shared list contributes; a scope that names `-c` in
|
|
492
|
+
// its own `deniedFlags` still means it.
|
|
493
|
+
const deniedInTail = scope.command === 'git' && GIT_SUBCOMMANDS_WITH_PLAIN_C.has(required[subcommandAt] ?? '')
|
|
494
|
+
? [...ALWAYS_DENIED_FLAGS.filter((flag) => flag !== '-c'), ...(scope.deniedFlags ?? [])]
|
|
495
|
+
: denied;
|
|
267
496
|
// A required token can itself be a flag or a refspec — an exact scope
|
|
268
497
|
// carries the whole approved command — and a whitelist file is
|
|
269
498
|
// hand-editable, so the prefix is held to the same rules as the rest.
|
|
270
|
-
for (const token of args.slice(0, required.length)) {
|
|
499
|
+
for (const [index, token] of args.slice(0, required.length).entries()) {
|
|
500
|
+
// A leading `-C <repo>` in a git scope is git's own directory flag, put
|
|
501
|
+
// there by `normalizeCommandScope`; the subcommand's refusal of `-C`
|
|
502
|
+
// (`git branch -C` copies) is about the tokens after the subcommand.
|
|
503
|
+
// Its operand is a path, not an argument of the subcommand's, so the
|
|
504
|
+
// subcommand's denied arguments and git's refspec rule do not apply to
|
|
505
|
+
// it either: `git -C add remote` is the `remote` subcommand in `add`.
|
|
506
|
+
if (scope.command === 'git' && subcommandAt > 0 && required.length > subcommandAt && index < subcommandAt) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
271
509
|
if (token.startsWith('-')) {
|
|
272
|
-
|
|
510
|
+
// Past the subcommand, the same relief the tail gets (`['switch', '-c']`).
|
|
511
|
+
if (deniesFlag(index > subcommandAt ? deniedInTail : denied, token)) {
|
|
273
512
|
return false;
|
|
274
513
|
}
|
|
275
514
|
continue;
|
|
@@ -281,20 +520,59 @@ export const matchesScope = (analysis, scope) => {
|
|
|
281
520
|
return false;
|
|
282
521
|
}
|
|
283
522
|
}
|
|
523
|
+
if (scope.literal && analysis.expands?.some((expands) => expands)) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
284
526
|
const rest = args.slice(required.length);
|
|
285
|
-
|
|
527
|
+
let positionals = 0;
|
|
528
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
529
|
+
const token = rest[index];
|
|
530
|
+
// After `--` a dash token is an operand to a program that honors it and
|
|
531
|
+
// a flag to one that doesn't, and the two can't both be checked as
|
|
532
|
+
// written: it asks.
|
|
533
|
+
// The required prefix counts: a scope may itself end in `--`.
|
|
534
|
+
if (token.startsWith('-') && args.slice(0, required.length + index).includes('--')) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
286
537
|
if (token.startsWith('-')) {
|
|
287
|
-
|
|
538
|
+
// `-cfix` is `-c fix`: the rest is the branch (or commit) it takes,
|
|
539
|
+
// not more flags, unless the scope itself denies `-c`.
|
|
540
|
+
// A short bundle with `c` in it (`-cfix`, `-qvcHEAD`): the letters
|
|
541
|
+
// before `c` are flags, and everything after it is `-c`'s value.
|
|
542
|
+
const at = !token.startsWith('--') ? token.indexOf('c', 1) : -1;
|
|
543
|
+
if (deniedInTail !== denied && at > 0 && token.length > at + 1) {
|
|
544
|
+
const before = [...token.slice(1, at)].map((letter) => `-${letter}`);
|
|
545
|
+
for (const flag of [...before, '-c']) {
|
|
546
|
+
// The same checks each flag meets on its own, minus reading the
|
|
547
|
+
// value as more flags; `-c` against the scope's own denials only.
|
|
548
|
+
const refusals = flag === '-c' ? scope.deniedFlags ?? [] : deniedInTail;
|
|
549
|
+
if (deniesFlag(refusals, flag) || (scope.allowedFlags && !allowsFlag(scope.allowedFlags, flag))) {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (deniesFlag(deniedInTail, token)) {
|
|
288
556
|
return false;
|
|
289
557
|
}
|
|
290
558
|
if (scope.allowedFlags && !allowsFlag(scope.allowedFlags, token)) {
|
|
291
559
|
return false;
|
|
292
560
|
}
|
|
561
|
+
if (scope.flagsWithValue?.includes(token)) {
|
|
562
|
+
if (index + 1 >= rest.length) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
index += 1;
|
|
566
|
+
}
|
|
293
567
|
continue;
|
|
294
568
|
}
|
|
295
569
|
if (scope.listOnly) {
|
|
296
570
|
return false;
|
|
297
571
|
}
|
|
572
|
+
positionals += 1;
|
|
573
|
+
if (scope.maxPositionals !== undefined && positionals > scope.maxPositionals) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
298
576
|
if (scope.deniedArgs?.includes(token)) {
|
|
299
577
|
return false;
|
|
300
578
|
}
|
|
@@ -304,6 +582,22 @@ export const matchesScope = (analysis, scope) => {
|
|
|
304
582
|
}
|
|
305
583
|
return true;
|
|
306
584
|
};
|
|
585
|
+
/**
|
|
586
|
+
* The scopes covering an invocation, one per command of a pipeline and one
|
|
587
|
+
* for a plain command, or undefined unless every command is covered.
|
|
588
|
+
*/
|
|
589
|
+
export const findCoveringScopes = (analysis, scopes) => {
|
|
590
|
+
const commands = analysis.pipeline ?? [analysis];
|
|
591
|
+
const covering = [];
|
|
592
|
+
for (const command of commands) {
|
|
593
|
+
const scope = findMatchingScope(command, scopes);
|
|
594
|
+
if (!scope) {
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
covering.push(scope);
|
|
598
|
+
}
|
|
599
|
+
return covering;
|
|
600
|
+
};
|
|
307
601
|
/** The first scope covering this invocation, if any covers it. */
|
|
308
602
|
export const findMatchingScope = (analysis, scopes) => scopes.find((scope) => matchesScope(analysis, scope));
|
|
309
603
|
/**
|
|
@@ -346,6 +640,23 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
346
640
|
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
347
641
|
return undefined;
|
|
348
642
|
}
|
|
643
|
+
// `git -C <repo> <subcommand> …` is `git <subcommand> …` run in <repo>,
|
|
644
|
+
// and an agent with worktrees spells nearly every git call this way. The
|
|
645
|
+
// repository is kept in the scope, literally, so a grant for one worktree
|
|
646
|
+
// says nothing about another; past it the subcommand is judged as it
|
|
647
|
+
// would be without -C. Only the leading position: anywhere else -C sits
|
|
648
|
+
// among flags of unknown arity, and the exact-command rule below applies.
|
|
649
|
+
if (analysis.base === 'git' && analysis.tokens[1] === '-C' && analysis.tokens.length > 3) {
|
|
650
|
+
const repo = analysis.tokens[2];
|
|
651
|
+
if (repo.length > 0 && !repo.startsWith('-') && !analysis.expands?.[2] && !/[*?[\]{}~$\\#]/.test(repo)) {
|
|
652
|
+
const inner = normalizeCommandScope({
|
|
653
|
+
...analysis,
|
|
654
|
+
tokens: ['git', ...analysis.tokens.slice(3)],
|
|
655
|
+
...(analysis.expands ? { expands: [analysis.expands[0] ?? false, ...analysis.expands.slice(3)] } : {}),
|
|
656
|
+
});
|
|
657
|
+
return inner === undefined ? undefined : { ...inner, args: ['-C', repo, ...(inner.args ?? [])] };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
349
660
|
const tokens = analysis.tokens.slice(1);
|
|
350
661
|
const firstIndex = tokens.findIndex((token) => !token.startsWith('-'));
|
|
351
662
|
const first = firstIndex === -1 ? undefined : tokens[firstIndex];
|
|
@@ -412,6 +723,14 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
412
723
|
...(refspecs ? { denyRefspecForms: true } : {}),
|
|
413
724
|
};
|
|
414
725
|
}
|
|
726
|
+
// The stored argument is what the scope is named for and which safe
|
|
727
|
+
// scope's constraints it inherits, so it must be the argument the shell
|
|
728
|
+
// passes: `git \branch --list` runs `git branch --list`, and a scope
|
|
729
|
+
// stored as `git \branch` would inherit nothing of `branch`'s list-only
|
|
730
|
+
// rule while matching `git \branch release`.
|
|
731
|
+
if (first !== undefined && (analysis.expands?.[firstIndex + 1] || /[*?[\]{}~$\\#]/.test(first))) {
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
415
734
|
const sameScope = SAFE_COMMAND_SCOPES
|
|
416
735
|
.filter((scope) => scope.command === analysis.base && (scope.args ?? []).join(' ') === (first ?? ''));
|
|
417
736
|
const inherited = sameScope.flatMap((scope) => scope.deniedFlags ?? []);
|
|
@@ -439,6 +758,105 @@ export const normalizeCommandScope = (analysis) => {
|
|
|
439
758
|
...(refspecs ? { denyRefspecForms: true } : {}),
|
|
440
759
|
};
|
|
441
760
|
};
|
|
761
|
+
/**
|
|
762
|
+
* The scope an operator declares in config (`approvals.commands`): a
|
|
763
|
+
* command and, optionally, the subcommands it is limited to — `agentboard`,
|
|
764
|
+
* `pnpm test`, `gh pr`. Whatever follows the prefix may vary, the way a
|
|
765
|
+
* remembered scope's arguments do, and the same things stay refused: the
|
|
766
|
+
* destructive flags, whatever the built-in list refuses for that command,
|
|
767
|
+
* and git's refspec deletes.
|
|
768
|
+
*
|
|
769
|
+
* Only words. A flag, an operator, or a token the shell would expand has no
|
|
770
|
+
* meaning as a prefix, and guessing one would be a grant nobody wrote, so
|
|
771
|
+
* the entry is refused with the reason instead.
|
|
772
|
+
*/
|
|
773
|
+
export const commandScopeFromPrefix = (prefix) => {
|
|
774
|
+
// Plain words only, so the entry as written is the scope as matched, and
|
|
775
|
+
// every listing and revoke can compare it word for word.
|
|
776
|
+
if (/['"]/.test(prefix)) {
|
|
777
|
+
return { reason: 'it contains quotes; write the command as plain words' };
|
|
778
|
+
}
|
|
779
|
+
const analysis = analyzeCommand(prefix.trim());
|
|
780
|
+
if (analysis.pipeline) {
|
|
781
|
+
return { reason: 'it is a pipeline; list each command on its own' };
|
|
782
|
+
}
|
|
783
|
+
if (analysis.disqualifiedBy || analysis.base === undefined) {
|
|
784
|
+
return { reason: analysis.disqualifiedBy ?? 'it could not be read as a command' };
|
|
785
|
+
}
|
|
786
|
+
const args = analysis.tokens.slice(1);
|
|
787
|
+
if (args.some((token) => token.startsWith('-'))) {
|
|
788
|
+
return { reason: 'it names a flag; list the command and its subcommands only' };
|
|
789
|
+
}
|
|
790
|
+
// A path is a program or a file, not a subcommand: `python scripts/x.py`
|
|
791
|
+
// would let any tail follow a script nobody reviewed by name.
|
|
792
|
+
if (args.some((token) => token.includes('/') || token.startsWith('.'))) {
|
|
793
|
+
return { reason: 'it names a path; list the command and its subcommands only' };
|
|
794
|
+
}
|
|
795
|
+
if (analysis.expands?.some((expands) => expands) || analysis.tokens.some((token) => /[*?[\]{}~$\\#]/.test(token))) {
|
|
796
|
+
return { reason: 'it contains something the shell would expand' };
|
|
797
|
+
}
|
|
798
|
+
const forBase = SAFE_COMMAND_SCOPES.filter((scope) => scope.command === analysis.base);
|
|
799
|
+
const declared = args.join(' ');
|
|
800
|
+
// A prefix shorter than a subcommand the built-in list limits would cover
|
|
801
|
+
// that subcommand's mutating forms: `git` would run `git branch release`,
|
|
802
|
+
// which the list's own `git branch` scope exists to refuse.
|
|
803
|
+
const narrower = forBase.find((scope) => {
|
|
804
|
+
const sub = scope.args ?? [];
|
|
805
|
+
return sub.length > args.length
|
|
806
|
+
&& args.every((token, index) => sub[index] === token)
|
|
807
|
+
&& (scope.listOnly || scope.allowedFlags || scope.maxPositionals !== undefined);
|
|
808
|
+
});
|
|
809
|
+
if (narrower) {
|
|
810
|
+
return { reason: `the built-in list limits \`${describeCommandScope(narrower)}\`; list the subcommands it may run instead` };
|
|
811
|
+
}
|
|
812
|
+
// Nor longer than a limited built-in scope: `grep fix` names grep's
|
|
813
|
+
// pattern, and as a prefix it would let any file follow it, which the
|
|
814
|
+
// built-in `grep` exists to refuse. The limits are about the arguments a
|
|
815
|
+
// prefix fixes in place, so there is no adjusting them; the entry is
|
|
816
|
+
// refused and the built-in scope already covers what it was safe for.
|
|
817
|
+
const extended = forBase.find((scope) => {
|
|
818
|
+
const sub = scope.args ?? [];
|
|
819
|
+
return sub.length < args.length
|
|
820
|
+
&& sub.every((token, index) => args[index] === token)
|
|
821
|
+
&& (scope.listOnly || scope.allowedFlags || scope.maxPositionals !== undefined || scope.literal || (scope.flagsWithValue ?? []).length > 0);
|
|
822
|
+
});
|
|
823
|
+
if (extended) {
|
|
824
|
+
return { reason: `it extends \`${describeCommandScope(extended)}\`, which the built-in list already limits; it runs unattended within those limits without an entry` };
|
|
825
|
+
}
|
|
826
|
+
// And the same prefix as a built-in scope keeps every limit it draws —
|
|
827
|
+
// list-only, the named flags, the positional count — never just the
|
|
828
|
+
// refusals: `git branch` must still not create a branch.
|
|
829
|
+
const same = forBase.filter((scope) => (scope.args ?? []).join(' ') === declared);
|
|
830
|
+
const allowedFlags = same.length > 0 && same.every((scope) => scope.allowedFlags)
|
|
831
|
+
? [...new Set(same.flatMap((scope) => scope.allowedFlags ?? []))]
|
|
832
|
+
: undefined;
|
|
833
|
+
const flagsWithValue = [...new Set(same.flatMap((scope) => scope.flagsWithValue ?? []))];
|
|
834
|
+
const positionals = same.map((scope) => scope.maxPositionals).filter((count) => count !== undefined);
|
|
835
|
+
// Refusals come only from built-in scopes on the same path as this one:
|
|
836
|
+
// `git remote` refuses `add` as its argument, which says nothing about
|
|
837
|
+
// `git add`, and copying it would refuse the entry's own subcommand.
|
|
838
|
+
const related = forBase.filter((scope) => {
|
|
839
|
+
const sub = scope.args ?? [];
|
|
840
|
+
const shorter = sub.length <= args.length ? sub : args;
|
|
841
|
+
const longer = sub.length <= args.length ? args : sub;
|
|
842
|
+
return shorter.every((token, index) => longer[index] === token);
|
|
843
|
+
});
|
|
844
|
+
const deniedArgs = related.flatMap((scope) => scope.deniedArgs ?? []);
|
|
845
|
+
return {
|
|
846
|
+
scope: {
|
|
847
|
+
command: analysis.base,
|
|
848
|
+
...(args.length > 0 ? { args } : {}),
|
|
849
|
+
deniedFlags: [...new Set([...DESTRUCTIVE_FLAGS, ...related.flatMap((scope) => scope.deniedFlags ?? [])])],
|
|
850
|
+
...(deniedArgs.length > 0 ? { deniedArgs: [...new Set(deniedArgs)] } : {}),
|
|
851
|
+
...(same.some((scope) => scope.listOnly) ? { listOnly: true } : {}),
|
|
852
|
+
...(allowedFlags ? { allowedFlags } : {}),
|
|
853
|
+
...(flagsWithValue.length > 0 ? { flagsWithValue } : {}),
|
|
854
|
+
...(positionals.length > 0 ? { maxPositionals: Math.min(...positionals) } : {}),
|
|
855
|
+
...(same.some((scope) => scope.literal) ? { literal: true } : {}),
|
|
856
|
+
...(analysis.base === 'git' ? { denyRefspecForms: true } : {}),
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
};
|
|
442
860
|
/** One line an operator can read in a log or a whitelist listing. */
|
|
443
861
|
export const describeCommandScope = (scope) => [scope.command, ...(scope.args ?? [])].join(' ');
|
|
444
862
|
/** Whether two scopes permit the same thing, so a whitelist does not grow duplicates. */
|
|
@@ -451,6 +869,9 @@ const normalizeForCompare = (scope) => ({
|
|
|
451
869
|
denyRefspecForms: scope.denyRefspecForms ?? false,
|
|
452
870
|
listOnly: scope.listOnly ?? false,
|
|
453
871
|
allowedFlags: [...(scope.allowedFlags ?? [])].sort(),
|
|
872
|
+
maxPositionals: scope.maxPositionals ?? null,
|
|
873
|
+
flagsWithValue: [...(scope.flagsWithValue ?? [])].sort(),
|
|
874
|
+
literal: scope.literal ?? false,
|
|
454
875
|
});
|
|
455
876
|
/** Read one scope out of a whitelist file, or refuse it. */
|
|
456
877
|
export const parseCommandScope = (raw) => {
|
|
@@ -466,6 +887,11 @@ export const parseCommandScope = (raw) => {
|
|
|
466
887
|
const deniedFlags = strings(source.deniedFlags);
|
|
467
888
|
const deniedArgs = strings(source.deniedArgs);
|
|
468
889
|
const allowedFlags = strings(source.allowedFlags);
|
|
890
|
+
const flagsWithValue = strings(source.flagsWithValue);
|
|
891
|
+
const maxPositionals = typeof source.maxPositionals === 'number'
|
|
892
|
+
&& Number.isInteger(source.maxPositionals) && source.maxPositionals >= 0
|
|
893
|
+
? source.maxPositionals
|
|
894
|
+
: undefined;
|
|
469
895
|
return {
|
|
470
896
|
command: source.command,
|
|
471
897
|
...(args ? { args } : {}),
|
|
@@ -474,6 +900,9 @@ export const parseCommandScope = (raw) => {
|
|
|
474
900
|
...(source.denyRefspecForms === true ? { denyRefspecForms: true } : {}),
|
|
475
901
|
...(source.listOnly === true ? { listOnly: true } : {}),
|
|
476
902
|
...(allowedFlags ? { allowedFlags } : {}),
|
|
903
|
+
...(maxPositionals !== undefined ? { maxPositionals } : {}),
|
|
904
|
+
...(flagsWithValue ? { flagsWithValue } : {}),
|
|
905
|
+
...(source.literal === true ? { literal: true } : {}),
|
|
477
906
|
};
|
|
478
907
|
};
|
|
479
908
|
//# sourceMappingURL=commands.js.map
|