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