opencode-harness-kit 0.5.4 → 0.5.5

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,115 @@ 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
+ * @param {string} command
619
+ * @returns {boolean}
620
+ */
621
+ function pipesScriptIntoShell(command) {
622
+ const stages = pipeStages(command)
623
+ for (let i = 1; i < stages.length; i++) {
624
+ if (!DOWNLOADERS.test(stages[i - 1])) continue
625
+ if (!runsScript(stages[i])) continue
626
+ return true
627
+ }
628
+ return false
629
+ }
630
+
631
+ /**
632
+ * Whether a command is an rm with a flag that makes it destructive.
633
+ *
634
+ * The flag can be a short cluster (`-rf`) or the long GNU name of the same
635
+ * two flags (`--recursive --force`), and it can be anywhere among the
636
+ * options. A rule that only knew the short form allowed the long one, which
637
+ * is the same command.
638
+ *
639
+ * @param {string} text the segment, with its prefixes stripped
640
+ * @returns {boolean}
641
+ */
642
+ function isRecursiveDelete(text) {
643
+ const tokens = tokenize(text)
644
+ if (tokens.length < 2) return false
645
+ if (baseName(tokens[0]).toLowerCase() !== "rm") return false
646
+ for (let i = 1; i < tokens.length; i++) {
647
+ const token = tokens[i]
648
+ if (!token.startsWith("-")) continue
649
+ if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue
650
+ // The flag needs something to destroy: `rm --recursive` on its own does
651
+ // nothing, and a rule that blocks it blocks a typo.
652
+ return tokens.slice(i + 1).some((t) => !t.startsWith("-"))
653
+ }
654
+ return false
363
655
  }
364
656
 
365
657
  /**
@@ -380,7 +672,19 @@ export const BASH_RULES = [
380
672
  {
381
673
  name: "secret exfiltration via shell",
382
674
  scope: "command",
383
- test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
675
+ // The credential word has to be the whole name or the tail of it: the
676
+ // name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
677
+ // never fires after an underscore, so the boundary alone left every
678
+ // prefixed credential name unmatched.
679
+ //
680
+ // The name is matched in whatever case it is written: the rule used to
681
+ // match the upper case spelling only, so `token=$(curl ...)` was
682
+ // allowed, and a lower case name is the same name.
683
+ //
684
+ // What the rule does NOT cover is an assignment whose value is not
685
+ // fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
686
+ // send one.
687
+ test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
384
688
  why: "assigns a fetched value into a credential-named variable",
385
689
  },
386
690
  {
@@ -392,7 +696,7 @@ export const BASH_RULES = [
392
696
  {
393
697
  name: "pipe remote script into a shell",
394
698
  scope: "command",
395
- test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
699
+ check: (text) => pipesScriptIntoShell(text),
396
700
  why: "downloads and executes a script in one step",
397
701
  },
398
702
  // `git push` is ask-gated via permissions (`*` ask in the generated
@@ -401,13 +705,16 @@ export const BASH_RULES = [
401
705
  {
402
706
  name: "recursive delete",
403
707
  scope: "segment",
404
- test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
708
+ check: (text) => isRecursiveDelete(text),
405
709
  why: "rm with a recursive or force flag",
406
710
  },
407
711
  {
408
712
  name: "chmod 777",
409
713
  scope: "segment",
410
- test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
714
+ // The mode may be written with leading zeros: `0777` and `777` are the
715
+ // same mode, and a rule that required the bare digits let one spelling
716
+ // of it through.
717
+ test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
411
718
  why: "opens permissions on everything",
412
719
  },
413
720
  {
@@ -485,7 +792,10 @@ export function checkDenied(command, depth = 0) {
485
792
  const parts = segments(command)
486
793
  const normalized = parts.map((seg) => stripPrefixes(seg))
487
794
  for (let i = 0; i < parts.length; i++) {
488
- const pattern = deniedBy(normalized[i])
795
+ // The tool's own global flags come off before the list is read: the
796
+ // list names a command, and `npm --prefix /tmp publish` is the command
797
+ // `npm publish`.
798
+ const pattern = deniedBy(stripToolFlags(normalized[i]))
489
799
  if (pattern) return { pattern, segment: parts[i] }
490
800
  }
491
801
  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