opencode-harness-kit 0.5.7 → 0.6.0

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
@@ -2,7 +2,8 @@
2
2
  // TypeScript runtime and reused by other tools.
3
3
 
4
4
  import { homedir, tmpdir } from "node:os"
5
- import { resolve as resolvePath, sep } from "node:path"
5
+ import { resolve as resolvePath, sep, dirname, basename, join } from "node:path"
6
+ import { realpathSync } from "node:fs"
6
7
  import { deniedBy } from "./denylist.js"
7
8
 
8
9
  // Two ideas here:
@@ -41,9 +42,46 @@ function expandHome(p) {
41
42
  return p
42
43
  }
43
44
 
45
+ /**
46
+ * The real path of a file, or of the deepest part of it that exists.
47
+ *
48
+ * A path that does not exist yet cannot be resolved, and a write usually
49
+ * names a file that does not exist yet - so the walk goes up until it finds
50
+ * something real and puts the missing tail back on the end. That is what a
51
+ * symlink in the middle of the path is resolved THROUGH: the link is in the
52
+ * part that exists.
53
+ *
54
+ * @param {string} p
55
+ * @returns {string}
56
+ */
57
+ function realpathOfNearestExisting(p) {
58
+ /** The parts of the path that do not exist yet. */
59
+ const missing = []
60
+ let current = p
61
+ for (;;) {
62
+ try {
63
+ const real = realpathSync(current)
64
+ return missing.length ? join(real, ...missing) : real
65
+ } catch {
66
+ // Not there yet. Go up one level, remember the part that is missing,
67
+ // and try again.
68
+ }
69
+ const parent = dirname(current)
70
+ if (parent === current) return current
71
+ missing.unshift(basename(current))
72
+ current = parent
73
+ }
74
+ }
75
+
44
76
  /**
45
77
  * Whether a write target stays inside the project. A relative path is resolved
46
78
  * against the project root, which is where the agent's working directory is.
79
+ *
80
+ * The comparison is made on the REAL path, not on the text of the path. A
81
+ * symlink inside the project that points at /etc is a path inside the
82
+ * project, and it is a write outside it - and the text of the path is the
83
+ * only thing the lexical comparison ever saw.
84
+ *
47
85
  * @param {string} target
48
86
  * @param {string} root
49
87
  * @returns {boolean}
@@ -53,8 +91,11 @@ export function insideRoot(target, root) {
53
91
  const absTarget = resolvePath(root, expandHome(target))
54
92
  const absRoot = resolvePath(root)
55
93
  if (absTarget === absRoot) return true
56
- const a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget
57
- const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot
94
+ const realTarget = realpathOfNearestExisting(absTarget)
95
+ const realRoot = realpathOfNearestExisting(absRoot)
96
+ if (realTarget === realRoot) return true
97
+ const a = CASE_INSENSITIVE_FS ? realTarget.toLowerCase() : realTarget
98
+ const b = CASE_INSENSITIVE_FS ? realRoot.toLowerCase() : realRoot
58
99
  return a.startsWith(b.endsWith(sep) ? b : b + sep)
59
100
  }
60
101
 
@@ -274,17 +315,53 @@ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
274
315
  // The wrappers that put another command in front of the real one. `npx`
275
316
  // runs a package's binary, so `npx npm publish` is `npm publish` with two
276
317
  // words in front of it.
277
- const GIT_WRAPPERS = new Set([
278
- "env",
279
- "sudo",
280
- "doas",
281
- "nohup",
282
- "time",
283
- "command",
284
- "exec",
285
- "xargs",
286
- "npx",
287
- ])
318
+ //
319
+ // The list is a table and not a set, because the flags that take a value are
320
+ // the wrapper's OWN: `sudo -n` is a flag (non-interactive) and the word
321
+ // after it is the COMMAND, while `stdbuf -o 0` takes a mode and the word
322
+ // after it is the mode. One list for all of them made `sudo -n rm -rf /tmp/x`
323
+ // read `rm` as the value of `-n`, so the delete was invisible - and the
324
+ // wrappers nobody thought of (`timeout`, `nice`, `setsid`, `busybox`) were
325
+ // commands the rules had never seen at all.
326
+ const WRAPPERS = {
327
+ env: new Set([
328
+ "-u", "--unset", "-C", "--chdir", "-S", "--split-string", "--argv0",
329
+ "--block-signal", "--default-signal", "--ignore-signal",
330
+ ]),
331
+ sudo: new Set([
332
+ "-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
333
+ "-C", "--close-from", "-T", "--command-timeout", "-R", "--chroot",
334
+ "-D", "--chdir", "--other-user",
335
+ ]),
336
+ doas: new Set(["-u", "--user"]),
337
+ nohup: new Set(),
338
+ time: new Set(["-f", "--file", "-o", "--format", "-p", "--portability"]),
339
+ command: new Set(["-p", "--path", "-v", "--verbose"]),
340
+ exec: new Set(["-a", "--argv0", "-c", "--command", "-l", "--login"]),
341
+ xargs: new Set([
342
+ "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace",
343
+ "-L", "--max-lines", "-n", "--max-args", "-P", "--max-procs",
344
+ "-s", "--max-chars", "-t", "--verbose", "-x", "--exit-on-error",
345
+ ]),
346
+ npx: new Set(["-p", "--package", "-c", "--call", "--registry"]),
347
+ // The wrappers that run a command and are not in the list above are the
348
+ // ones that were missing from it, and every one of them was a way around
349
+ // the rules.
350
+ timeout: new Set(["-k", "--kill-after", "-s", "--signal"]),
351
+ nice: new Set(["-n", "--adjustment"]),
352
+ ionice: new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid", "-t"]),
353
+ stdbuf: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]),
354
+ setsid: new Set(),
355
+ chroot: new Set(["-u", "--users", "-g", "--groups", "--userspec", "--groupspec"]),
356
+ taskset: new Set(["-c", "--cpu-list", "-p", "--pid"]),
357
+ busybox: new Set(),
358
+ }
359
+ /**
360
+ * The same table, typed for the lookups by name.
361
+ *
362
+ * @type {Record<string, Set<string>>}
363
+ */
364
+ const WRAPPER_TABLE = WRAPPERS
288
365
  // The shells a script can be piped into, by name. The name is what matters:
289
366
  // `/bin/sh` is the same shell as `sh`.
290
367
  const SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i
@@ -295,6 +372,8 @@ const SCRIPT_RUNNERS = /^python[0-9.]*$/i
295
372
  const DOWNLOADERS = /\b(curl|wget)\b/i
296
373
  // Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
297
374
  const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
375
+ // A duration, as `timeout` spells it: a number, and optionally a unit.
376
+ const DURATION = /^\d+(\.\d+)?[smhd]?$/
298
377
  // git options that take a value, so the subcommand is the word after them.
299
378
  const GIT_VALUE_FLAGS = new Set([
300
379
  "-C",
@@ -378,6 +457,39 @@ function baseName(word) {
378
457
  return slash === -1 ? word : word.slice(slash + 1)
379
458
  }
380
459
 
460
+ /**
461
+ * The index just past a wrapper's own arguments.
462
+ *
463
+ * A wrapper is a word that runs another word, so the words between the two
464
+ * are the wrapper's arguments and not the command. Which words those are
465
+ * depends on the wrapper: `timeout` takes a DURATION (`timeout 5 rm ...`),
466
+ * `chroot` takes a DIRECTORY (`chroot /jail rm ...`), and the rest take
467
+ * flags.
468
+ *
469
+ * @param {string[]} tokens
470
+ * @param {number} i the index just past the wrapper word
471
+ * @param {string} name the wrapper's name
472
+ * @returns {number} the index of the word after the wrapper's arguments
473
+ */
474
+ function skipWrapperArgs(tokens, i, name) {
475
+ const valueFlags = WRAPPER_TABLE[name] ?? WRAPPER_VALUE_FLAGS
476
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
477
+ const flag = tokens[i]
478
+ i++
479
+ // `--flag=value` carries its own value, so there is no word to skip.
480
+ if (valueFlags.has(flag) && !flag.includes("=") && i < tokens.length) i++
481
+ }
482
+ // `timeout 5 rm -rf /tmp/x`: the duration is the wrapper's, not the
483
+ // command's. It is only skipped when it LOOKS like a duration, so a
484
+ // command that happens to start with a number is still a command.
485
+ if (name === "timeout" && i < tokens.length && DURATION.test(tokens[i])) i++
486
+ // `chroot /jail rm -rf /tmp/x`: the directory is the wrapper's. It is
487
+ // always there - chroot cannot run without one - so the word after the
488
+ // flags is the directory and the word after THAT is the command.
489
+ if (name === "chroot" && i < tokens.length && !tokens[i].startsWith("-")) i++
490
+ return i
491
+ }
492
+
381
493
  /**
382
494
  * Skip the wrappers and environment assignments in front of a real command.
383
495
  * `env FOO=1 sudo -u deploy git -C /repo push` resolves at index 4.
@@ -397,13 +509,9 @@ function skipPrefixes(tokens) {
397
509
  // the raw token left the whole wrapper list one absolute path away from
398
510
  // being decorative: `/usr/bin/env rm -rf /` was not a command the rules
399
511
  // had ever seen.
400
- if (GIT_WRAPPERS.has(baseName(word))) {
401
- i++
402
- while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
403
- const flag = tokens[i]
404
- i++
405
- if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++
406
- }
512
+ const name = baseName(word)
513
+ if (name in WRAPPER_TABLE) {
514
+ i = skipWrapperArgs(tokens, i + 1, name)
407
515
  continue
408
516
  }
409
517
  return i
@@ -473,6 +581,15 @@ export function gitSubcommand(segment) {
473
581
  // would resolve to `push`.
474
582
  while (i < tokens.length) {
475
583
  const flag = tokens[i]
584
+ // A value flag with its value attached: `git -C/tmp reset --hard` is
585
+ // `git -C /tmp reset --hard`, and `git -ccore.pager=cat clean -f` is
586
+ // `git -c core.pager=cat clean -f`. The value was part of the same
587
+ // word, so the resolver read the FLAG as the subcommand - and the rule
588
+ // that watches for a history rewrite never saw the subcommand at all.
589
+ if (flag.length > 2 && !flag.startsWith("--") && (flag.startsWith("-C") || flag.startsWith("-c"))) {
590
+ i++
591
+ continue
592
+ }
476
593
  if (GIT_VALUE_FLAGS.has(flag)) {
477
594
  i += 2
478
595
  continue
@@ -528,6 +645,50 @@ function shellCommandString(tokens, from) {
528
645
  return null
529
646
  }
530
647
 
648
+ /**
649
+ * The command line hidden inside `env -S "..."`.
650
+ *
651
+ * `env -S` takes a string and SPLITS it into words, so the string is a
652
+ * command line - the same thing `sh -c` carries. It used to be invisible:
653
+ * the string arrived as ONE token, and the command word was read out of it
654
+ * as the text after the last slash, so `env -S "rm -rf /tmp/x"` was read as
655
+ * a command called `x`.
656
+ *
657
+ * @param {string[]} tokens
658
+ * @returns {string | null}
659
+ */
660
+ function envSplitPayload(tokens) {
661
+ for (let i = 0; i < tokens.length; i++) {
662
+ const word = tokens[i]
663
+ if (word === undefined) return null
664
+ if (ENV_ASSIGN.test(word)) continue
665
+ const name = baseName(word)
666
+ if (name === "env") {
667
+ // The flag and its value, in the three spellings env accepts.
668
+ for (let j = i + 1; j < tokens.length; j++) {
669
+ const token = tokens[j]
670
+ if (token === "-S" || token === "--split-string") {
671
+ return tokens[j + 1] ?? null
672
+ }
673
+ if (token.startsWith("--split-string=")) {
674
+ return token.slice("--split-string=".length) || null
675
+ }
676
+ if (token.startsWith("-S") && token.length > 2) {
677
+ return token.slice(2) || null
678
+ }
679
+ if (ENV_ASSIGN.test(token)) continue
680
+ if (!token.startsWith("-")) break
681
+ }
682
+ return null
683
+ }
684
+ // Only `env` itself carries the flag. A command that happens to have
685
+ // `env` further along the line is not an env.
686
+ if (!(name in WRAPPER_TABLE)) return null
687
+ i = skipWrapperArgs(tokens, i + 1, name) - 1
688
+ }
689
+ return null
690
+ }
691
+
531
692
  /**
532
693
  * The command line hidden inside `sh -c "..."` or `eval "..."`.
533
694
  * @param {string} segment
@@ -536,6 +697,11 @@ function shellCommandString(tokens, from) {
536
697
  export function shellPayload(segment) {
537
698
  const tokens = tokenize(segment)
538
699
  if (tokens.length < 2) return null
700
+ // `env -S "..."` carries a command line too, and it is read before the
701
+ // wrappers: the flag and its value are the wrapper's, so the wrapper scan
702
+ // walks straight past them.
703
+ const split = envSplitPayload(tokens)
704
+ if (split) return split
539
705
  // The wrappers come off FIRST. `sudo bash -c "rm -rf /"` hides a whole
540
706
  // command line behind one argument, and the shell doing the hiding is the
541
707
  // second word - so looking only at the first word missed every command
@@ -777,6 +943,107 @@ function opensPermissions(text) {
777
943
  */
778
944
  export const ASK_GATED = [/^\s*git\s+push\b/]
779
945
 
946
+ /**
947
+ * The file a download writes to, or null when the segment is not a download.
948
+ *
949
+ * `curl -o FILE URL` and `wget -O FILE URL` fetch a URL into a file. The
950
+ * file is named by the flag, and the flag carries its value in three
951
+ * spellings: two words, one word, and the long form with an `=`.
952
+ *
953
+ * @param {string} segment
954
+ * @returns {string | null}
955
+ */
956
+ function downloadTarget(segment) {
957
+ const tokens = tokenize(segment)
958
+ const i = skipPrefixes(tokens)
959
+ if (i >= tokens.length) return null
960
+ const name = baseName(tokens[i]).toLowerCase()
961
+ if (name !== "curl" && name !== "wget") return null
962
+ for (let j = i + 1; j < tokens.length; j++) {
963
+ const token = tokens[j]
964
+ if (token === "--") break
965
+ if (token === "-o" || token === "--output" || token === "-O" || token === "--output-document") {
966
+ const value = tokens[j + 1]
967
+ return value && !value.startsWith("-") ? value : null
968
+ }
969
+ if (token.startsWith("--output=") || token.startsWith("--output-document=")) {
970
+ return token.slice(token.indexOf("=") + 1) || null
971
+ }
972
+ // `wget -Ofile` is one word.
973
+ if (name === "wget" && token.startsWith("-O") && token.length > 2) {
974
+ return token.slice(2) || null
975
+ }
976
+ }
977
+ return null
978
+ }
979
+
980
+ /**
981
+ * Whether two words name the same file.
982
+ *
983
+ * The comparison is exact when both names carry a directory, and by the last
984
+ * segment otherwise - so `x.sh` and `/tmp/x.sh` are the same file, and
985
+ * `/tmp/x.sh` and `/tmp/other/x.sh` are not.
986
+ *
987
+ * @param {string} a
988
+ * @param {string} b
989
+ * @returns {boolean}
990
+ */
991
+ function sameFile(a, b) {
992
+ if (a === b) return true
993
+ if (!a.includes("/") || !b.includes("/")) return baseName(a) === baseName(b)
994
+ return false
995
+ }
996
+
997
+ /**
998
+ * Whether a segment runs a file.
999
+ *
1000
+ * Either the file is the command itself (`/tmp/x.sh`), or it is an argument
1001
+ * of a shell or an interpreter (`bash /tmp/x.sh`, `python3 /tmp/x.py`).
1002
+ *
1003
+ * @param {string} segment
1004
+ * @param {string} file
1005
+ * @returns {boolean}
1006
+ */
1007
+ function runsFile(segment, file) {
1008
+ const tokens = tokenize(segment)
1009
+ const i = skipPrefixes(tokens)
1010
+ if (i >= tokens.length) return false
1011
+ if (sameFile(tokens[i], file)) return true
1012
+ const name = baseName(tokens[i])
1013
+ const runner =
1014
+ SHELL_NAMES.test(name) ||
1015
+ SCRIPT_RUNNERS.test(name) ||
1016
+ name === "source" ||
1017
+ name === "."
1018
+ if (!runner) return false
1019
+ for (let j = i + 1; j < tokens.length; j++) {
1020
+ if (sameFile(tokens[j], file)) return true
1021
+ }
1022
+ return false
1023
+ }
1024
+
1025
+ /**
1026
+ * Whether a command fetches a file and then runs it.
1027
+ *
1028
+ * The pipe form (`curl x | sh`) is one rule and this is the other: the same
1029
+ * download in two steps - fetch it to a file, then run the file - was
1030
+ * ordinary work, because the rule only ever looked at a pipe.
1031
+ *
1032
+ * @param {string} command
1033
+ * @returns {boolean}
1034
+ */
1035
+ function downloadsAndRuns(command) {
1036
+ const parts = segments(command)
1037
+ for (let i = 0; i < parts.length; i++) {
1038
+ const file = downloadTarget(parts[i])
1039
+ if (!file) continue
1040
+ for (let j = i + 1; j < parts.length; j++) {
1041
+ if (runsFile(parts[j], file)) return true
1042
+ }
1043
+ }
1044
+ return false
1045
+ }
1046
+
780
1047
  /** @type {Rule[]} */
781
1048
  export const BASH_RULES = [
782
1049
  {
@@ -809,6 +1076,16 @@ export const BASH_RULES = [
809
1076
  check: (text) => pipesScriptIntoShell(text),
810
1077
  why: "downloads and executes a script in one step",
811
1078
  },
1079
+ {
1080
+ name: "download and run",
1081
+ scope: "command",
1082
+ // The pipe rule above is the same download in ONE step. This is the
1083
+ // same download in TWO: fetch it to a file, then run the file. The
1084
+ // rules only ever looked at a pipe, so the two-step form was ordinary
1085
+ // work - and it runs exactly the same downloaded script.
1086
+ check: (text) => downloadsAndRuns(text),
1087
+ why: "downloads a script to a file and then runs it",
1088
+ },
812
1089
  // `git push` is ask-gated via permissions (`*` ask in the generated
813
1090
  // config), not guard-blocked. Publishing needs human approval, but the
814
1091
  // guard lets it through so opencode can prompt first.
@@ -867,6 +1144,11 @@ export const WRITE_RULES = [
867
1144
  {
868
1145
  name: "write to a private key",
869
1146
  test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
1147
+ // The PUBLIC half of a key pair is a file the user is meant to publish.
1148
+ // The rule named the key FILES, and `id_rsa.pub` is one of them, so
1149
+ // writing a public key - the ordinary thing to do with a key - was
1150
+ // refused for a reason nobody could find.
1151
+ unless: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\.pub(\.[^/\\]*)?$/i,
870
1152
  why: "private key material",
871
1153
  },
872
1154
  ]
@@ -1,45 +0,0 @@
1
- {
2
- "$comment": "Metadata verified against models.dev on 2026-10-19 and confirmed by a live call the same day. Regenerate the capability fields with `oc-harness refresh longcat-2.5-preview-free`.",
3
- "id": "longcat-2.5-preview-free",
4
- "title": "LongCat 2.5 Preview Free",
5
- "provider": "opencode",
6
- "model": "longcat-2.5-preview-free",
7
- "family": "longcat",
8
- "verified": "2026-10-21",
9
- "retention": "zero-retention",
10
- "notes": "Meituan. 1M context with image input, and a reasoning toggle rather than a tier, so no variant is written. It read a screenshot correctly in a live call, which is what makes it the zero-retention fallback for the vision lane. Measured at 9.4-13.2s. A preview endpoint: it can be withdrawn without a rename.",
11
- "limits": {
12
- "context": 1000000,
13
- "output": 131072
14
- },
15
- "supports": {
16
- "reasoning": true,
17
- "temperature": false,
18
- "toolCall": true,
19
- "structuredOutput": false,
20
- "attachment": true
21
- },
22
- "effort": {
23
- "levels": [],
24
- "default": null,
25
- "reserveShare": 0.12
26
- },
27
- "roles": {
28
- "plan": "max",
29
- "build": "max",
30
- "reviewer": "max",
31
- "locate": "low",
32
- "verify": "low"
33
- },
34
- "prune": {
35
- "enabled": true,
36
- "keepRecentToolCalls": 6,
37
- "keepRecentTurns": 3,
38
- "keepUserMessages": true
39
- },
40
- "fallback": [
41
- "minimax-m3.1-flash-free",
42
- "deepseek-v4.1-flash-free",
43
- "beam-free"
44
- ]
45
- }