opencode-harness-kit 0.5.4 → 0.5.6

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/src/rules.js CHANGED
@@ -93,12 +93,18 @@ function heredocEnd(command, at) {
93
93
  }
94
94
 
95
95
  /**
96
- * Split a shell command into the individual commands it runs. Separators
97
- * inside quotes, and heredoc bodies, are not command boundaries.
96
+ * Split a shell command at the separators of one kind.
97
+ *
98
+ * The quoting, the escaping and the heredoc body are handled once, so the
99
+ * command splitter and the pipe splitter can never disagree about where a
100
+ * command starts and ends.
101
+ *
98
102
  * @param {string} command
103
+ * @param {"command" | "pipe"} kind `command` splits at every separator,
104
+ * `pipe` splits at the pipe only
99
105
  * @returns {string[]}
100
106
  */
101
- export function segments(command) {
107
+ function split(command, kind) {
102
108
  /** @type {string[]} */
103
109
  const parts = []
104
110
  let cur = ""
@@ -139,13 +145,30 @@ export function segments(command) {
139
145
  continue
140
146
  }
141
147
  }
142
- if (ch === "\n" || ch === ";") {
148
+ if (ch === "|") {
149
+ if (kind === "pipe") {
150
+ // `||` is a logical or, not a pipe: the second command runs only if
151
+ // the first one failed, and nothing was piped into it.
152
+ if (command[i + 1] === "|") {
153
+ cur += "||"
154
+ i++
155
+ continue
156
+ }
157
+ flush()
158
+ continue
159
+ }
143
160
  flush()
161
+ if (command[i + 1] === "|") i++
144
162
  continue
145
163
  }
146
- if (ch === "|") {
164
+ // A pipe stage is everything between two pipes. A `;` or an `&&` inside
165
+ // a stage is still part of that stage.
166
+ if (kind === "pipe") {
167
+ cur += ch
168
+ continue
169
+ }
170
+ if (ch === "\n" || ch === ";") {
147
171
  flush()
148
- if (command[i + 1] === "|") i++
149
172
  continue
150
173
  }
151
174
  if (ch === "&") {
@@ -172,6 +195,30 @@ export function segments(command) {
172
195
  return parts
173
196
  }
174
197
 
198
+ /**
199
+ * Split a shell command into the individual commands it runs. Separators
200
+ * inside quotes, and heredoc bodies, are not command boundaries.
201
+ * @param {string} command
202
+ * @returns {string[]}
203
+ */
204
+ export function segments(command) {
205
+ return split(command, "command")
206
+ }
207
+
208
+ /**
209
+ * Split a shell command into the stages a pipe connects.
210
+ *
211
+ * The command splitter loses the pipe: `curl x | sh` is two commands, and
212
+ * nothing in the two says which fed which. A rule about "download and run"
213
+ * needs that link, so it reads the pipe itself.
214
+ *
215
+ * @param {string} command
216
+ * @returns {string[]}
217
+ */
218
+ function pipeStages(command) {
219
+ return split(command, "pipe")
220
+ }
221
+
175
222
  /**
176
223
  * Split one segment into words, honouring quotes so that a quoted phrase is
177
224
  * one argument rather than several.
@@ -238,6 +285,14 @@ const GIT_WRAPPERS = new Set([
238
285
  "xargs",
239
286
  "npx",
240
287
  ])
288
+ // The shells a script can be piped into, by name. The name is what matters:
289
+ // `/bin/sh` is the same shell as `sh`.
290
+ const SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i
291
+ // Python is not a shell, but `curl x | python3` runs what it was given
292
+ // just the same.
293
+ const SCRIPT_RUNNERS = /^python[0-9.]*$/i
294
+ // The commands that fetch something over the network.
295
+ const DOWNLOADERS = /\b(curl|wget)\b/i
241
296
  // Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
242
297
  const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
243
298
  // git options that take a value, so the subcommand is the word after them.
@@ -262,6 +317,67 @@ const GIT_GLOBAL_FLAGS = new Set([
262
317
  "--icase-pathspecs",
263
318
  ])
264
319
 
320
+ /**
321
+ * The global flags each tool accepts, and that take a value.
322
+ *
323
+ * A denylist pattern names a COMMAND, so a command with a global flag in
324
+ * front of it is not the command the pattern describes: `npm --prefix /tmp
325
+ * publish` is `npm publish`, and `gh -R owner/repo pr create` is `gh pr
326
+ * create`. A flag that changes nothing about what the command does must not
327
+ * be a way around the list.
328
+ *
329
+ * The lists come from the tools' own `--help` output: `gh --help` lists
330
+ * `--repo` and `--hostname` among the global options, `npm help 7 config`
331
+ * lists `--prefix`, `--workspace`, `--registry` and `--cache` among the
332
+ * global config flags, `yarn --help` lists `--cwd`, and `pnpm --help` lists
333
+ * `--dir` and `--filter`.
334
+ *
335
+ * git is not in the table: its own options are resolved by `gitSubcommand`,
336
+ * which reads the subcommand rather than the whole command line.
337
+ *
338
+ * @type {Record<string, Set<string>>}
339
+ */
340
+ const TOOL_VALUE_FLAGS = {
341
+ gh: new Set(["-R", "--repo", "-H", "--hostname"]),
342
+ npm: new Set([
343
+ "--prefix",
344
+ "--cache",
345
+ "--registry",
346
+ "--userconfig",
347
+ "--globalconfig",
348
+ "--otp",
349
+ "--tag",
350
+ "--access",
351
+ "--loglevel",
352
+ "--workspace",
353
+ "--script-shell",
354
+ "--init-author-name",
355
+ "--init-author-email",
356
+ "--init-author-url",
357
+ "--init-license",
358
+ "--init-module",
359
+ "--init-version",
360
+ ]),
361
+ yarn: new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
362
+ pnpm: new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
363
+ bun: new Set(["--cwd", "--config", "--registry"]),
364
+ }
365
+
366
+ /**
367
+ * The last path segment of a command word: `/usr/bin/env` is `env`.
368
+ *
369
+ * A tool named by its full path is the same tool, so every list of command
370
+ * names - the wrappers, the shells - has to be matched against the name and
371
+ * not against the path.
372
+ *
373
+ * @param {string} word
374
+ * @returns {string}
375
+ */
376
+ function baseName(word) {
377
+ const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"))
378
+ return slash === -1 ? word : word.slice(slash + 1)
379
+ }
380
+
265
381
  /**
266
382
  * Skip the wrappers and environment assignments in front of a real command.
267
383
  * `env FOO=1 sudo -u deploy git -C /repo push` resolves at index 4.
@@ -277,7 +393,11 @@ function skipPrefixes(tokens) {
277
393
  i++
278
394
  continue
279
395
  }
280
- if (GIT_WRAPPERS.has(word)) {
396
+ // The wrapper is matched by NAME, so `/usr/bin/env` is `env`. Matching
397
+ // the raw token left the whole wrapper list one absolute path away from
398
+ // being decorative: `/usr/bin/env rm -rf /` was not a command the rules
399
+ // had ever seen.
400
+ if (GIT_WRAPPERS.has(baseName(word))) {
281
401
  i++
282
402
  while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
283
403
  const flag = tokens[i]
@@ -303,11 +423,39 @@ export function stripPrefixes(segment) {
303
423
  const i = skipPrefixes(tokens)
304
424
  if (i >= tokens.length) return ""
305
425
  const rest = tokens.slice(i)
306
- const slash = Math.max(rest[0].lastIndexOf("/"), rest[0].lastIndexOf("\\"))
307
- if (slash !== -1) rest[0] = rest[0].slice(slash + 1)
426
+ rest[0] = baseName(rest[0])
308
427
  return rest.join(" ")
309
428
  }
310
429
 
430
+ /**
431
+ * A command with the tool's own global flags taken out.
432
+ *
433
+ * The flags are taken out wherever they appear, because that is where the
434
+ * tool itself accepts them: `gh pr --repo owner/repo create` is the same
435
+ * command as `gh pr create`, and gh's own flag parser reads it that way.
436
+ *
437
+ * @param {string} command
438
+ * @returns {string} the command as the tool will read it
439
+ */
440
+ function stripToolFlags(command) {
441
+ const tokens = tokenize(command)
442
+ if (!tokens.length) return command
443
+ const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()]
444
+ if (!flags) return command
445
+ /** @type {string[]} */
446
+ const out = []
447
+ for (let i = 0; i < tokens.length; i++) {
448
+ const token = tokens[i]
449
+ if (!flags.has(token.split("=")[0])) {
450
+ out.push(token)
451
+ continue
452
+ }
453
+ // `--flag value` takes the next word; `--flag=value` carries its own.
454
+ if (!token.includes("=") && i + 1 < tokens.length) i++
455
+ }
456
+ return out.join(" ")
457
+ }
458
+
311
459
  /**
312
460
  * Resolve the git subcommand a segment actually runs, or null if the segment
313
461
  * does not run git.
@@ -338,9 +486,48 @@ export function gitSubcommand(segment) {
338
486
  return tokens[i] ?? null
339
487
  }
340
488
 
341
- const SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i
342
489
  const MAX_SHELL_DEPTH = 2
343
490
 
491
+ /**
492
+ * The command string a shell's `-c` flag carries.
493
+ *
494
+ * The shell reads the operand of `-c` from the NEXT argv element, whatever
495
+ * else is in the flag cluster: `bash -cv "rm -rf /"` runs `rm -rf /`, and
496
+ * the `v` is a flag, not a payload. Reading the rest of the word instead
497
+ * made the guard look at the letter `v` and at nothing else, so the
498
+ * command line it checked was a letter.
499
+ *
500
+ * A payload written INTO the word (`bash -c"rm -rf /"`) is a different
501
+ * spelling. bash and dash do not read it as the command line at all: they
502
+ * read the whole word as a cluster of option characters and refuse it
503
+ * (`bash: invalid option`). The guard reads the remainder as the command
504
+ * string anyway, because that is how the payload is spelled, and because
505
+ * refusing more than the shell would run is the safe direction - the guard
506
+ * is not the place to decide that a word is harmless.
507
+ *
508
+ * @param {string[]} tokens
509
+ * @param {number} from the index of the shell word
510
+ * @returns {string | null} the command line, or null when there is none
511
+ */
512
+ function shellCommandString(tokens, from) {
513
+ for (let n = from + 1; n < tokens.length; n++) {
514
+ const token = tokens[n]
515
+ if (!token.startsWith("-") || token === "-") continue
516
+ // The flag cluster is the run of letters after the dash, and `c` is the
517
+ // flag that takes the command line.
518
+ const m = /^-([a-zA-Z]*)c/.exec(token)
519
+ if (!m) continue
520
+ // What follows the `c` in the same word is either the rest of the
521
+ // cluster (`-cv`, where the `v` is a flag) or the command line written
522
+ // into the word (`-c"rm -rf /"`, where the remainder has something in it
523
+ // that cannot be a flag). A run of letters is a cluster.
524
+ const after = token.slice(m[0].length)
525
+ if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim()
526
+ return tokens.slice(n + 1).join(" ") || null
527
+ }
528
+ return null
529
+ }
530
+
344
531
  /**
345
532
  * The command line hidden inside `sh -c "..."` or `eval "..."`.
346
533
  * @param {string} segment
@@ -356,10 +543,225 @@ export function shellPayload(segment) {
356
543
  const i = skipPrefixes(tokens)
357
544
  if (i >= tokens.length) return null
358
545
  if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null
359
- if (!SHELL_BIN.test(tokens[i])) return null
360
- const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t))
361
- if (c === -1 || c === tokens.length - 1) return null
362
- return tokens.slice(c + 1).join(" ") || null
546
+ if (!SHELL_NAMES.test(baseName(tokens[i]))) return null
547
+ return shellCommandString(tokens, i)
548
+ }
549
+
550
+ /**
551
+ * The punctuation that opens a group of commands and the punctuation that
552
+ * ends a word in a command list.
553
+ *
554
+ * `sh;` is the shell `sh` is - the `;` ends the word, it does not make the
555
+ * word something else. Reading the word whole made every pipe with a `;`
556
+ * after the shell look like a pipe into something that is not a shell.
557
+ *
558
+ * The opening braces are here for the same reason: the contents of a group
559
+ * read the pipe the same way a command outside it does, so `{ sh; }` is a
560
+ * pipe into a shell even though the first word of the group is `{`.
561
+ */
562
+ const GROUP_OPEN = /^[({[]+/
563
+ const GROUP_END = /[;)&,}\]]+$/
564
+
565
+ /**
566
+ * The name of the command a word names, with the path and the punctuation
567
+ * that frames it taken off.
568
+ * @param {string} word
569
+ * @returns {string}
570
+ */
571
+ function commandName(word) {
572
+ return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "")
573
+ }
574
+
575
+ /**
576
+ * Whether a command hands what it was given to a shell or an interpreter.
577
+ *
578
+ * A pipe stage is not always one command. `sh; echo done` is a command
579
+ * list, and `cd /tmp && sh` is a list whose shell is not first at all, so
580
+ * the stage is split into its commands and each one is looked at. The
581
+ * wrappers in front of a command are skipped, so `sudo /bin/bash` is the
582
+ * shell `bash` is, and the path is dropped, so `/bin/sh` is `sh`.
583
+ *
584
+ * @param {string} stage one stage of a pipe
585
+ * @returns {boolean}
586
+ */
587
+ function runsScript(stage) {
588
+ for (const command of segments(stage)) {
589
+ const tokens = tokenize(command)
590
+ let i = skipPrefixes(tokens)
591
+ if (i >= tokens.length) continue
592
+ let name = commandName(tokens[i])
593
+ // A word that is nothing but an opener is a group, and the group's
594
+ // contents are the command: `{ sh; }` reads the pipe through the `sh`.
595
+ if (!name) {
596
+ i++
597
+ if (i >= tokens.length) continue
598
+ name = commandName(tokens[i])
599
+ }
600
+ if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true
601
+ }
602
+ return false
603
+ }
604
+
605
+ /**
606
+ * Whether a command downloads something and pipes it into a shell.
607
+ *
608
+ * Both halves are read the way the shell reads them. The pipe target is
609
+ * named by its base name, so `/bin/sh` is the shell `sh` is, and the
610
+ * wrappers in front of it are skipped, so `sudo /bin/bash` is the shell
611
+ * `bash` is. A rule that only knew the bare spelling of the shell was one
612
+ * absolute path away from being decorative.
613
+ *
614
+ * The target stage is read as the list of commands it is: `curl x | sh;` is
615
+ * still a pipe into a shell, because the `;` ends the word and not the
616
+ * pipe.
617
+ *
618
+ * The shell does not have to be the stage right after the download. A filter
619
+ * in between (`curl x | grep y | sh`) does not change what the shell RUNS -
620
+ * it changes only what the agent sees on the way there - so the chain is
621
+ * the same download-and-execute. The rule used to look at the stage after
622
+ * the download only, so a filter in the middle hid the shell from it.
623
+ *
624
+ * The order still matters: the shell has to come AFTER the download.
625
+ * `sh | grep y` runs a shell over a pipe it did not fetch, and there is
626
+ * nothing downloaded for it to execute.
627
+ *
628
+ * @param {string} command
629
+ * @returns {boolean}
630
+ */
631
+ function pipesScriptIntoShell(command) {
632
+ const stages = pipeStages(command)
633
+ for (let i = 0; i < stages.length; i++) {
634
+ if (!DOWNLOADERS.test(stages[i])) continue
635
+ for (let j = i + 1; j < stages.length; j++) {
636
+ if (!runsScript(stages[j])) continue
637
+ return true
638
+ }
639
+ }
640
+ return false
641
+ }
642
+
643
+ /**
644
+ * Whether a token is a flag that makes an rm destructive.
645
+ *
646
+ * The short form is a CLUSTER of letters, so `-rf`, `-fr` and `-R` all name
647
+ * a recursive delete and `-f` names a forced one. The long form is GNU's
648
+ * own name for the same two flags.
649
+ *
650
+ * @param {string} token
651
+ * @returns {boolean}
652
+ */
653
+ function isDestructiveRmFlag(token) {
654
+ if (/^--(recursive|force)$/i.test(token)) return true
655
+ return /^-[a-zA-Z]*[rf]/i.test(token)
656
+ }
657
+
658
+ /**
659
+ * Whether a command is an rm with a flag that makes it destructive.
660
+ *
661
+ * The flags and the paths are read SEPARATELY, because GNU rm PERMUTES its
662
+ * options: the flags can come after the path, and `rm /tmp/x -rf` is the
663
+ * same delete as `rm -rf /tmp/x`. The rule used to return at the FIRST flag
664
+ * token and then look for a path AFTER it, so a flag that came after the
665
+ * path was invisible and the delete the rule exists to stop went through.
666
+ *
667
+ * So the question is not "is the first flag recursive" but "is ANY of the
668
+ * flags recursive or force, AND is there a path" - a delete of one named
669
+ * file (`rm file.txt`, `rm -i notes.md`) is not a recursive delete, and a
670
+ * command with no path at all (`rm`, `rm -rf`) is not a delete.
671
+ *
672
+ * `--` ends the options, so every word after it is a path whatever it looks
673
+ * like. That makes `rm -- -rf` a delete of a file NAMED `-rf`, and not a
674
+ * recursive delete.
675
+ *
676
+ * @param {string} text the segment, with its prefixes stripped
677
+ * @returns {boolean}
678
+ */
679
+ function isRecursiveDelete(text) {
680
+ const tokens = tokenize(text)
681
+ if (tokens.length < 2) return false
682
+ if (baseName(tokens[0]).toLowerCase() !== "rm") return false
683
+ let destructive = false
684
+ let hasPath = false
685
+ for (let i = 1; i < tokens.length; i++) {
686
+ const token = tokens[i]
687
+ if (token === "--") {
688
+ // Everything after `--` is a path, so the words that follow it are
689
+ // the path this rm destroys.
690
+ hasPath = tokens.length > i + 1
691
+ break
692
+ }
693
+ if (!token.startsWith("-")) {
694
+ hasPath = true
695
+ continue
696
+ }
697
+ if (isDestructiveRmFlag(token)) destructive = true
698
+ }
699
+ return destructive && hasPath
700
+ }
701
+
702
+ /**
703
+ * The chmod options that carry their value in the NEXT word.
704
+ *
705
+ * `chmod --reference FILE` takes the mode from another file, so the word
706
+ * after it is not the mode. A scanner that read that word as the mode would
707
+ * see `chmod --reference 777 file` as "chmod 777", and that command is a
708
+ * file named 777 rather than a mode.
709
+ */
710
+ const CHMOD_VALUE_OPTIONS = new Set(["--reference"])
711
+
712
+ /**
713
+ * The mode a chmod command sets, or null when it sets none.
714
+ *
715
+ * The mode is the first numeric OPERAND, and every option in front of it is
716
+ * skipped - INCLUDING the long forms. The rule used to accept only a short
717
+ * letter cluster in the flag position, so GNU's own `--recursive` put the
718
+ * mode out of the rule's reach: `chmod --recursive 777 /` was allowed while
719
+ * `chmod -R 777 /` was blocked, and the mode is the same mode.
720
+ *
721
+ * A symbolic mode (`a=rwx`) is not a numeric one, so it is not a mode this
722
+ * function returns. That is a policy gap and not a spelling one, and it is
723
+ * left alone on purpose.
724
+ *
725
+ * @param {string} text the segment, with its prefixes stripped
726
+ * @returns {string | null} the mode, as it was written
727
+ */
728
+ function chmodMode(text) {
729
+ const tokens = tokenize(text)
730
+ if (tokens.length < 2) return null
731
+ if (baseName(tokens[0]).toLowerCase() !== "chmod") return null
732
+ for (let i = 1; i < tokens.length; i++) {
733
+ const token = tokens[i]
734
+ if (token === "--") continue
735
+ if (token.startsWith("--")) {
736
+ // `--flag=value` carries its own value, and a boolean long option
737
+ // carries none. Only the option that takes the next word swallows
738
+ // it, and that word is then not the mode.
739
+ if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++
740
+ continue
741
+ }
742
+ if (token.startsWith("-")) continue
743
+ // The first numeric operand is the mode; the words after it are the
744
+ // files the mode is applied to.
745
+ if (/^\d+$/.test(token)) return token
746
+ return null
747
+ }
748
+ return null
749
+ }
750
+
751
+ /**
752
+ * Whether a chmod command sets the mode that opens permissions to everyone.
753
+ *
754
+ * The mode may be written with leading zeros: `0777` and `777` are the same
755
+ * mode, and a rule that required the bare digits let one spelling of it
756
+ * through.
757
+ *
758
+ * @param {string} text the segment, with its prefixes stripped
759
+ * @returns {boolean}
760
+ */
761
+ function opensPermissions(text) {
762
+ const mode = chmodMode(text)
763
+ if (mode === null) return false
764
+ return /^0*777$/.test(mode)
363
765
  }
364
766
 
365
767
  /**
@@ -380,7 +782,19 @@ export const BASH_RULES = [
380
782
  {
381
783
  name: "secret exfiltration via shell",
382
784
  scope: "command",
383
- test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
785
+ // The credential word has to be the whole name or the tail of it: the
786
+ // name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
787
+ // never fires after an underscore, so the boundary alone left every
788
+ // prefixed credential name unmatched.
789
+ //
790
+ // The name is matched in whatever case it is written: the rule used to
791
+ // match the upper case spelling only, so `token=$(curl ...)` was
792
+ // allowed, and a lower case name is the same name.
793
+ //
794
+ // What the rule does NOT cover is an assignment whose value is not
795
+ // fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
796
+ // send one.
797
+ test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
384
798
  why: "assigns a fetched value into a credential-named variable",
385
799
  },
386
800
  {
@@ -392,7 +806,7 @@ export const BASH_RULES = [
392
806
  {
393
807
  name: "pipe remote script into a shell",
394
808
  scope: "command",
395
- test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
809
+ check: (text) => pipesScriptIntoShell(text),
396
810
  why: "downloads and executes a script in one step",
397
811
  },
398
812
  // `git push` is ask-gated via permissions (`*` ask in the generated
@@ -401,13 +815,17 @@ export const BASH_RULES = [
401
815
  {
402
816
  name: "recursive delete",
403
817
  scope: "segment",
404
- test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
818
+ check: (text) => isRecursiveDelete(text),
405
819
  why: "rm with a recursive or force flag",
406
820
  },
407
821
  {
408
822
  name: "chmod 777",
409
823
  scope: "segment",
410
- test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
824
+ // The mode is read as an OPERAND rather than as a literal, because the
825
+ // flags in front of it can be long. `chmod --recursive 777 /` is the
826
+ // same chmod as `chmod -R 777 /`, and a rule that only knew the short
827
+ // flag cluster let the long spelling through.
828
+ check: (text) => opensPermissions(text),
411
829
  why: "opens permissions on everything",
412
830
  },
413
831
  {
@@ -485,7 +903,10 @@ export function checkDenied(command, depth = 0) {
485
903
  const parts = segments(command)
486
904
  const normalized = parts.map((seg) => stripPrefixes(seg))
487
905
  for (let i = 0; i < parts.length; i++) {
488
- const pattern = deniedBy(normalized[i])
906
+ // The tool's own global flags come off before the list is read: the
907
+ // list names a command, and `npm --prefix /tmp publish` is the command
908
+ // `npm publish`.
909
+ const pattern = deniedBy(stripToolFlags(normalized[i]))
489
910
  if (pattern) return { pattern, segment: parts[i] }
490
911
  }
491
912
  if (depth < MAX_SHELL_DEPTH) {
package/src/sqlite.js CHANGED
@@ -92,6 +92,71 @@ function encodePath(db) {
92
92
  return db.split("/").map(encodeURIComponent).join("/")
93
93
  }
94
94
 
95
+ /**
96
+ * Whether a SQL string holds one statement.
97
+ *
98
+ * A semicolon inside a string literal or a comment does not end a
99
+ * statement, so the text is scanned rather than split on ";".
100
+ *
101
+ * Exported so a test can pin the rule without a database: the refusal is
102
+ * a promise about what the reader accepts, and a promise that can only be
103
+ * checked against a live database is a promise nobody checks.
104
+ *
105
+ * @param {string} sql
106
+ * @returns {boolean}
107
+ */
108
+ export function isOneStatement(sql) {
109
+ let quote = ""
110
+ // Whether a statement has been terminated. Everything after the `;` that
111
+ // is not whitespace or a comment is a SECOND statement, and a comment
112
+ // after the `;` is not a statement at all.
113
+ let ended = false
114
+ for (let i = 0; i < sql.length; i++) {
115
+ const ch = sql[i]
116
+ if (quote) {
117
+ if (ch === quote) {
118
+ // A doubled quote inside a literal is an escaped quote, not the end
119
+ // of the literal.
120
+ if (sql[i + 1] === quote) i++
121
+ else quote = ""
122
+ }
123
+ continue
124
+ }
125
+ if (ch === "'" || ch === '"' || ch === "`") {
126
+ quote = ch
127
+ continue
128
+ }
129
+ // A line comment runs to the end of the line.
130
+ if (ch === "-" && sql[i + 1] === "-") {
131
+ const end = sql.indexOf("\n", i)
132
+ // The comment runs to the end of the text, so nothing follows it.
133
+ if (end < 0) break
134
+ i = end
135
+ continue
136
+ }
137
+ // A block comment runs to its own end.
138
+ if (ch === "/" && sql[i + 1] === "*") {
139
+ const end = sql.indexOf("*/", i + 2)
140
+ if (end < 0) break
141
+ i = end + 1
142
+ continue
143
+ }
144
+ if (ch === ";") {
145
+ // A second `;` is a second statement - an empty one, which is still a
146
+ // statement the caller did not ask for.
147
+ if (ended) return false
148
+ ended = true
149
+ continue
150
+ }
151
+ // The scan does not stop at the `;`. A comment after the last
152
+ // statement is a comment, and the old check - "is there anything after
153
+ // the semicolon?" - read it as a second statement and refused
154
+ // `"select 1; -- done"`.
155
+ if (ended && !/\s/.test(ch)) return false
156
+ }
157
+ return true
158
+ }
159
+
95
160
  /**
96
161
  * Run a query and return the rows.
97
162
  *
@@ -105,6 +170,21 @@ function encodePath(db) {
105
170
  * @returns {any[]}
106
171
  */
107
172
  export function query(db, sql) {
173
+ // ONE statement, on both backends.
174
+ //
175
+ // The sqlite3 CLI runs the whole string, so a call with two statements ran
176
+ // them both; python3's execute() takes one statement and raised. The same
177
+ // call therefore behaved differently depending on which backend the
178
+ // machine happened to have - and on one side the failure was a database
179
+ // error about a statement the caller never wrote on its own.
180
+ //
181
+ // REFUSING is the behaviour chosen. The function returns ROWS, and the
182
+ // rows of two statements have no agreed meaning: the caller asked for one
183
+ // answer and would be handed two. A caller that wants two answers asks
184
+ // twice, and it says which one it wants each time.
185
+ if (!isOneStatement(sql)) {
186
+ throw new Error("query() runs one statement at a time - split the query into separate calls")
187
+ }
108
188
  if (hasSqlite()) {
109
189
  // The path is percent-encoded, because it goes into a URI. A `?` in a
110
190
  // path is a query separator, so a database called "we?ird.db" was read