opencode-harness-kit 0.6.3 → 0.7.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 +303 -10
- package/README.md +244 -14
- package/bin/oc-harness.mjs +234 -28
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/profiles/index.json +1 -12
- package/prompts/reviewer.md +24 -0
- package/retention.yml +0 -8
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/src/rules.js
CHANGED
|
@@ -1122,9 +1122,154 @@ export const BASH_RULES = [
|
|
|
1122
1122
|
},
|
|
1123
1123
|
why: "destroys work that only exists locally",
|
|
1124
1124
|
},
|
|
1125
|
+
{
|
|
1126
|
+
name: "destructive python via python -c",
|
|
1127
|
+
scope: "segment",
|
|
1128
|
+
// The permission list allows `python3 -c*` unprompted, because a python
|
|
1129
|
+
// one-liner is the ordinary way to read a sqlite file or count tokens -
|
|
1130
|
+
// and the shells rules read NONE of what it runs. `python3 -c "import
|
|
1131
|
+
// shutil; shutil.rmtree('/')"` is `rm -rf /` in a script the guard
|
|
1132
|
+
// never looked at.
|
|
1133
|
+
//
|
|
1134
|
+
// Only recursive tree surgery is caught, and only when the target is not
|
|
1135
|
+
// obviously scratch: an argument whose name is some spelling of tmp or
|
|
1136
|
+
// temp is a script cleaning its own scratch directory, which the kit
|
|
1137
|
+
// itself tells agents to use, and blocking that would break the
|
|
1138
|
+
// workflow the harness sets up. A variable, an absolute path, a quoted
|
|
1139
|
+
// literal that is not a temp name - all block, because "not obviously
|
|
1140
|
+
// scratch" is the honest statement about what a caller can know.
|
|
1141
|
+
check: (text) => embeddedPythonDestroys(text),
|
|
1142
|
+
why: "recursive deletion by script, hidden from the shell rules",
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
name: "destructive javascript via node -e",
|
|
1146
|
+
scope: "segment",
|
|
1147
|
+
// Same hole, and the list allows `node -e*` unprompted the same way:
|
|
1148
|
+
// `node -e "require('fs').rmSync('/x',{recursive:true,force:true})"` is
|
|
1149
|
+
// `rm -rf` the shell rules never saw. Same scratch carve-out, for the
|
|
1150
|
+
// same reason and with the same limit.
|
|
1151
|
+
check: (text) => embeddedNodeDestroys(text),
|
|
1152
|
+
why: "recursive deletion by script, hidden from the shell rules",
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
name: "find with -delete or -exec rm",
|
|
1156
|
+
scope: "segment",
|
|
1157
|
+
// The list allows `find*` unprompted, and a find that deletes is a walk
|
|
1158
|
+
// that deletes everything it passes: `find . -delete` is `rm -rf .` with
|
|
1159
|
+
// extra steps, and `-exec rm` is the same with the filter made visible.
|
|
1160
|
+
// The segment's first word is the command, so `man find` - a page the
|
|
1161
|
+
// agent reads, not runs - is an ordinary read and stays one here too.
|
|
1162
|
+
check: (text) => findDestroys(text),
|
|
1163
|
+
why: "a walk that deletes everything it passes",
|
|
1164
|
+
},
|
|
1125
1165
|
]
|
|
1126
1166
|
|
|
1127
1167
|
/** @type {Rule[]} */
|
|
1168
|
+
/**
|
|
1169
|
+
* The program text a runner segment carries: `python3 -c 'code'`, `node -e
|
|
1170
|
+
* "code"`, `node --eval 'code'`, with any runner flags in front. The shell
|
|
1171
|
+
* wrappers (`bash -c`) have their own helper - `shellPayload` - because they
|
|
1172
|
+
* take a whole command line; these runners take a program, and only the
|
|
1173
|
+
* program inside the quoting is worth reading.
|
|
1174
|
+
*
|
|
1175
|
+
* Every such payload in the segment is returned, not just the first: `python
|
|
1176
|
+
* -c '...' && node -e '...'` carries two programs and both need looking at.
|
|
1177
|
+
*
|
|
1178
|
+
* @param {string} text the segment, prefixes stripped
|
|
1179
|
+
* @returns {string[]}
|
|
1180
|
+
*/
|
|
1181
|
+
function evalPrograms(text) {
|
|
1182
|
+
const out = []
|
|
1183
|
+
// The runner, its leading options, then the program flag. What FOLLOWS the
|
|
1184
|
+
// flag is the program.
|
|
1185
|
+
const re = /(?:^|[;&|\n]|\s)(?:python3?|node)\s+(?:-(?:[\w.]+)\s+)*(?:-c|--eval|-e)\s*/g
|
|
1186
|
+
for (const m of text.matchAll(re)) {
|
|
1187
|
+
const code = text.slice(m.index + m[0].length)
|
|
1188
|
+
if (code.trim()) out.push(code)
|
|
1189
|
+
}
|
|
1190
|
+
return out
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* An argument's own name is a scratch directory when something about it says
|
|
1195
|
+
* so: a /tmp path, the shutil or Node tempdir helper, a variable the caller
|
|
1196
|
+
* named for scratch. This is an allowance for the workflow the kit sets up -
|
|
1197
|
+
* agents keep scratch in temp dirs - and it catches NAME spellings only.
|
|
1198
|
+
* A variable named `path` or `dir` is not scratch by name, and stays blocked.
|
|
1199
|
+
*
|
|
1200
|
+
* @param {string} arg
|
|
1201
|
+
* @returns {boolean}
|
|
1202
|
+
*/
|
|
1203
|
+
function scratchish(arg) {
|
|
1204
|
+
return /tmp|temp|scratch/i.test(arg) || /\btmpdir\b/.test(arg)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Whether a `python -c` payload does what `rm -rf` does.
|
|
1209
|
+
*
|
|
1210
|
+
* `shutil.rmtree` is THE recursive delete of python. `os.rmdir` takes one
|
|
1211
|
+
* empty directory, `os.remove` one file, and a single-file cleanup is
|
|
1212
|
+
* ordinary work - the rule reads the recursive call only, and only when the
|
|
1213
|
+
* target is not named scratch.
|
|
1214
|
+
*
|
|
1215
|
+
* @param {string} text
|
|
1216
|
+
* @returns {boolean}
|
|
1217
|
+
*/
|
|
1218
|
+
function embeddedPythonDestroys(text) {
|
|
1219
|
+
for (const code of evalPrograms(text)) {
|
|
1220
|
+
const call = /shutil\s*\.\s*rmtree\s*\(\s*([^,)]*)/.exec(code)
|
|
1221
|
+
if (!call) continue
|
|
1222
|
+
// No argument at all is a syntax error at best and unjudgable at worst -
|
|
1223
|
+
// an unjudgable delete blocks, the same way an unknown write does.
|
|
1224
|
+
if (!call[1] || !scratchish(call[1])) return true
|
|
1225
|
+
}
|
|
1226
|
+
return false
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Whether a `node -e` payload does what `rm -rf` does.
|
|
1231
|
+
*
|
|
1232
|
+
* `fs.rmSync(..., { recursive: true })` and legacy `rmdirSync(...,
|
|
1233
|
+
* { recursive: true })` are the recursive deletes of Node. The `recursive`
|
|
1234
|
+
* option is what makes it the tree surgery worth naming; a non-recursive
|
|
1235
|
+
* rmSync deletes exactly one file, which is ordinary work.
|
|
1236
|
+
*
|
|
1237
|
+
* @param {string} text
|
|
1238
|
+
* @returns {boolean}
|
|
1239
|
+
*/
|
|
1240
|
+
function embeddedNodeDestroys(text) {
|
|
1241
|
+
for (const code of evalPrograms(text)) {
|
|
1242
|
+
for (const m of code.matchAll(/(?:rmSync|rmdirSync)\s*\(\s*([^,)]*)/g)) {
|
|
1243
|
+
// The FLAG to look for is on the options object, not the first arg:
|
|
1244
|
+
// `rmSync(p, { recursive: true })`. Read further into the whole code
|
|
1245
|
+
// only when the call named no options in the call itself - an options
|
|
1246
|
+
// object split across a variable is still read, because the option
|
|
1247
|
+
// has to be SOMEWHERE in the program for it to matter.
|
|
1248
|
+
if (!/recursive\s*:\s*true/.test(code)) continue
|
|
1249
|
+
if (!m[1] || !scratchish(m[1])) return true
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return false
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Whether a find segment deletes what it walks.
|
|
1257
|
+
*
|
|
1258
|
+
* `-delete` is the flag; `-exec rm` and `-execdir rm` are the same thing
|
|
1259
|
+
* spelled the long way, and an `env rm` between them (`-exec env rm -rf`)
|
|
1260
|
+
* is the same command through the env wrapper the rest of the rules strip.
|
|
1261
|
+
*
|
|
1262
|
+
* @param {string} text the segment, prefixes stripped - first word find
|
|
1263
|
+
* @returns {boolean}
|
|
1264
|
+
*/
|
|
1265
|
+
function findDestroys(text) {
|
|
1266
|
+
if (!/^find(?:\s|$)/.test(text)) return false
|
|
1267
|
+
return (
|
|
1268
|
+
/(?:^|\s)--?delete\b/.test(text) ||
|
|
1269
|
+
/(?:^|\s)-exec(?:dir)?\s+(?:env\s+\S+\s+)*(rm|sh)\b/.test(text)
|
|
1270
|
+
)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1128
1273
|
export const WRITE_RULES = [
|
|
1129
1274
|
{
|
|
1130
1275
|
// `unless` exists because the safe variants are the same shape as the
|
package/src/tasks.js
CHANGED
|
@@ -185,6 +185,37 @@ export const isDone = (t) => t.checked || t.status === "done"
|
|
|
185
185
|
/** @param {Task} t */
|
|
186
186
|
export const isBlocked = (t) => t.status === "blocked"
|
|
187
187
|
|
|
188
|
+
// A command that cannot fail proves nothing. `true`, `:`, `echo ok` and
|
|
189
|
+
// `sleep 0` all exit 0 whether or not the change landed, so a task whose whole
|
|
190
|
+
// verify line is one of them would be marked done by a run that did nothing.
|
|
191
|
+
//
|
|
192
|
+
// Redirection is excluded on purpose: `echo ok > out.txt` writes a file, and
|
|
193
|
+
// catching that is a different (and much harder) question than catching a
|
|
194
|
+
// command with no effect at all.
|
|
195
|
+
const vacuousRe = /^(?:true|:|exit(?:\s+0)?|echo(?:\s+[^;&|<>]*)?|printf(?:\s+[^;&|<>]*)?|sleep\s+0+)$/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Is this verify command guaranteed to exit 0 whatever the agent did?
|
|
199
|
+
*
|
|
200
|
+
* A command with a real step anywhere in it is not vacuous: `true && npm test`
|
|
201
|
+
* still fails when the tests fail, so it is accepted. Only a command made
|
|
202
|
+
* entirely of no-ops is refused.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} command
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
export function isVacuousVerify(command) {
|
|
208
|
+
const body = String(command ?? "")
|
|
209
|
+
.replace(/\s+#.*$/, "")
|
|
210
|
+
.trim()
|
|
211
|
+
if (!body) return true
|
|
212
|
+
const parts = body
|
|
213
|
+
.split(/\s*(?:&&|\|\||;|\n)\s*/)
|
|
214
|
+
.map((p) => p.trim())
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
return parts.length === 0 || parts.every((p) => vacuousRe.test(p))
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
/**
|
|
189
220
|
* Record the outcome of a task: flip the checkbox, set the status, and record
|
|
190
221
|
* the verify output that was actually observed.
|