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/CHANGELOG.md +330 -0
- package/README.md +241 -11
- package/bin/baseline.mjs +16 -1
- package/bin/oc-harness.mjs +528 -42
- package/dist/index.js +294 -57
- package/dist/profile.d.ts +2 -1
- package/dist/rules.d.ts +6 -0
- package/docs/KNOWN-LIMITS.md +22 -2
- package/lanes.json +2 -2
- package/package.json +1 -1
- package/profiles/beam-free.json +2 -3
- package/profiles/deepseek-v4.1-flash-free.json +1 -2
- package/profiles/fleet-all.json +6 -14
- package/profiles/fleet-safe.json +8 -19
- package/profiles/glm-5.3-flash-free.json +1 -2
- package/profiles/index.json +4 -11
- package/profiles/mimo-v2.6-flash-free.json +1 -2
- package/profiles/minimax-m3.1-flash-free.json +1 -2
- package/profiles/nemotron-3.5-lightning-free.json +2 -3
- package/profiles/nemotron-3.5-super-vl-free.json +1 -2
- package/profiles/schema.json +8 -0
- package/retention.yml +0 -9
- package/src/config.js +9 -1
- package/src/profile.js +14 -1
- package/src/prune.ts +90 -5
- package/src/research.js +462 -0
- package/src/rules.js +303 -21
- package/profiles/longcat-2.5-preview-free.json +0 -45
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
|
|
57
|
-
const
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
}
|