opencode-harness-kit 0.6.4 → 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/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.