@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.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
- if (operator.pattern.test(command)) {
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 tokens = tokenize(command);
196
- if (!tokens || tokens.length === 0) {
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
- return letters.length > 0 && letters.every((letter) => allowed.includes(letter));
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
- if (deniesFlag(denied, token)) {
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
- for (const token of rest) {
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
- if (deniesFlag(denied, token)) {
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