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/CHANGELOG.md +165 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +452 -68
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +18 -7
- package/eval/measure-prune.mjs +13 -4
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +46 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +329 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/trust.js +182 -54
package/dist/profile.d.ts
CHANGED
|
@@ -307,8 +307,21 @@ export declare function pruneThreshold(p: Profile, opts?: {
|
|
|
307
307
|
unhealthy?: Set<string>;
|
|
308
308
|
}): number;
|
|
309
309
|
/**
|
|
310
|
-
* Every model a profile can put behind the user: the base model
|
|
311
|
-
* each lane actually resolves to.
|
|
310
|
+
* Every model a profile can put behind the user: the base model, the model
|
|
311
|
+
* each lane actually resolves to, and the utility route.
|
|
312
|
+
*
|
|
313
|
+
* The utility route belongs in this list because it is WRITTEN into the
|
|
314
|
+
* config as `small_model` and opencode sends real requests down it - a
|
|
315
|
+
* title, a one-line summary, a rewritten question. It used to be a chain
|
|
316
|
+
* rung only, so it never appeared here, and a profile could name a
|
|
317
|
+
* may-train utility route under a zero-retention claim: the file said
|
|
318
|
+
* "zero-retention" while a training provider served the calls.
|
|
319
|
+
*
|
|
320
|
+
* Every caller of this list wants the same thing - the models that can
|
|
321
|
+
* serve a request - so the list is widened rather than a second one
|
|
322
|
+
* invented beside it. The one caller that needs the lanes alone is
|
|
323
|
+
* `validate()`, which cross-checks the profile's DECLARED retention against
|
|
324
|
+
* the base model and the lanes; that is `laneModelsInPlay`.
|
|
312
325
|
*
|
|
313
326
|
* @param {Profile} p
|
|
314
327
|
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
@@ -317,6 +330,23 @@ export declare function pruneThreshold(p: Profile, opts?: {
|
|
|
317
330
|
export declare function modelsInPlay(p: Profile, opts?: {
|
|
318
331
|
unhealthy?: Set<string>;
|
|
319
332
|
}): string[];
|
|
333
|
+
/**
|
|
334
|
+
* The models the profile's own base and lanes put behind the user, without
|
|
335
|
+
* the utility route.
|
|
336
|
+
*
|
|
337
|
+
* This is the set the profile's own `retention` field is about: the base
|
|
338
|
+
* model and the lanes it resolves to. The utility route is an extra route
|
|
339
|
+
* the profile adds on top of them, and it is reported where it belongs - the
|
|
340
|
+
* config header names it, and `oc-harness use` warns about it - so it does
|
|
341
|
+
* not decide what the profile may declare about itself.
|
|
342
|
+
*
|
|
343
|
+
* @param {Profile} p
|
|
344
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
345
|
+
* @returns {string[]}
|
|
346
|
+
*/
|
|
347
|
+
export declare function laneModelsInPlay(p: Profile, opts?: {
|
|
348
|
+
unhealthy?: Set<string>;
|
|
349
|
+
}): string[];
|
|
320
350
|
/**
|
|
321
351
|
* The split lanes, each with the limits of the model that lane actually runs
|
|
322
352
|
* on. A lane on a different model has a different output cap, and the header
|
|
@@ -3,6 +3,28 @@ export declare const MAX_BYTES: number;
|
|
|
3
3
|
export declare function logPath(dir: string): string;
|
|
4
4
|
/** The telemetry file and its rotated predecessors, oldest first. */
|
|
5
5
|
export declare function logFiles(dir: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Rotate by size so a long-running project cannot fill its disk.
|
|
8
|
+
*
|
|
9
|
+
* THREE generations are kept: the current file, `.1` and `.2`. The reader
|
|
10
|
+
* enumerates all three, so the writer has to produce all three.
|
|
11
|
+
*
|
|
12
|
+
* The rotation used to rename the current file straight to `.1`, and a
|
|
13
|
+
* rename REPLACES the destination on POSIX - so the previous generation was
|
|
14
|
+
* destroyed on every rotation and `.2` never came into existence. Two
|
|
15
|
+
* rotations after the fact, the oldest history was gone and the third
|
|
16
|
+
* generation the reader looks for was missing: the history a project lost
|
|
17
|
+
* was lost on a schedule nobody was watching.
|
|
18
|
+
*
|
|
19
|
+
* So the oldest is shifted out of the way first, and the oldest of the
|
|
20
|
+
* three is dropped - the disk use stays bounded at three files, and the
|
|
21
|
+
* generation that is dropped is the one that was already two rotations
|
|
22
|
+
* old.
|
|
23
|
+
*
|
|
24
|
+
* @param file the file to rotate
|
|
25
|
+
* @param maxBytes the size above which it is rotated
|
|
26
|
+
*/
|
|
27
|
+
export declare function rotate(file: string, maxBytes?: number): void;
|
|
6
28
|
/**
|
|
7
29
|
* Append one record.
|
|
8
30
|
*
|
package/docs/EXIT-CODES.md
CHANGED
|
@@ -75,6 +75,13 @@ attributable measurement carries the gate.
|
|
|
75
75
|
| 0 | The install is in place, or the change was confirmed. Something may have been left alone - a file that was already there, a prompt that was not answered - and the reason is printed. |
|
|
76
76
|
| 1 | The install was refused, or a step failed. |
|
|
77
77
|
|
|
78
|
+
A target that was left alone is NOT counted as installed: the install says
|
|
79
|
+
so in place of its tick, and `verify` reports the file that is not the kit's
|
|
80
|
+
- because a file of the user's own at `GLOBAL.md` means the kit's rules are
|
|
81
|
+
not being loaded, and a tick next to that is a claim the installer cannot
|
|
82
|
+
make. A link that could not be created is a step that failed, and the
|
|
83
|
+
install exits 1.
|
|
84
|
+
|
|
78
85
|
`doctor` also exits 1 when there is no config, or no profile recorded in
|
|
79
86
|
it: a kit that is installed but points at nothing is a broken install, and
|
|
80
87
|
a check that passes it is not a check.
|
package/docs/KNOWN-LIMITS.md
CHANGED
|
@@ -361,13 +361,24 @@ re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
|
|
|
361
361
|
cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
|
|
362
362
|
turning it off brings the old behaviour back.
|
|
363
363
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
364
|
+
`savedChars` in `prune.jsonl` is the size of the request before the pass minus
|
|
365
|
+
the size after it, measured on the parts as they are serialised - not on the
|
|
366
|
+
text inside them. It used to measure the text only, and to ignore the state
|
|
367
|
+
the stub put in a part's place, which carries about fifty characters of its
|
|
368
|
+
own metadata per stubbed part. So a pass whose request GREW was reported as
|
|
369
|
+
a saving of the difference between two texts.
|
|
370
|
+
|
|
371
|
+
A negative `savedChars` therefore means the pass made the request BIGGER: the
|
|
372
|
+
stub was longer than the output it replaced, because the stub carries its own
|
|
373
|
+
label and its own metadata. Such a pass is still logged, because a part was
|
|
374
|
+
stubbed, which is the event worth recording, and the negative number is the
|
|
375
|
+
honest report of it.
|
|
376
|
+
|
|
377
|
+
There is no minimum size under which a part is left alone. This file used to
|
|
378
|
+
claim a 200 byte floor for `reasoning` parts; there was never such a floor in
|
|
379
|
+
the code, and a reasoning part shorter than the stub - about seventy-eight
|
|
380
|
+
characters - is still replaced, so the request grows. The measurement above
|
|
381
|
+
is what makes that visible instead of silent.
|
|
371
382
|
|
|
372
383
|
`baseline.mjs` measures permission pressure with its own glob approximation of
|
|
373
384
|
OpenCode's matcher, not OpenCode's matcher. It handles the cases that matter
|
package/eval/measure-prune.mjs
CHANGED
|
@@ -186,11 +186,20 @@ const hook = await HarnessPlugin(
|
|
|
186
186
|
const transform = hook["experimental.chat.messages.transform"]
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* The size
|
|
189
|
+
* The size this measurement counts.
|
|
190
190
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
191
|
+
* CHARACTERS, not bytes: `part.text.length`. The report labelled that
|
|
192
|
+
* "bytes", which is wrong for anything that is not ASCII - and an agent's
|
|
193
|
+
* output is not ASCII. Both numbers are reported.
|
|
194
|
+
*
|
|
195
|
+
* This counts the TEXT of every part, while the pruner's own log counts the
|
|
196
|
+
* PART - and only the parts the pruner touched. So the two numbers answer
|
|
197
|
+
* two different questions and are not the same number twice: this one asks
|
|
198
|
+
* how much TEXT the pruner removed from a stored session, the log asks how
|
|
199
|
+
* much the REQUEST that went out shrank. The stub the pruner leaves behind
|
|
200
|
+
* carries about fifty characters of metadata of its own, so the log's
|
|
201
|
+
* saving is the smaller of the two, and it can even be negative where this
|
|
202
|
+
* one is positive. The gap is stated rather than hidden.
|
|
194
203
|
*/
|
|
195
204
|
function sizeOf(part) {
|
|
196
205
|
if (typeof part?.text === "string") return part.text.length
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
|
package/src/config.js
CHANGED
|
@@ -158,6 +158,11 @@ export function utilityModel(p) {
|
|
|
158
158
|
* `instructions` array used to replace it wholesale, which unloaded the
|
|
159
159
|
* whole behavioural layer while everything still looked correct.
|
|
160
160
|
*
|
|
161
|
+
* A SINGLE string is a list of one. The parameter has always been typed
|
|
162
|
+
* `string[] | string | undefined`, but a string was dropped on the floor:
|
|
163
|
+
* the file that came back named only the kit's GLOBAL.md, so the user's own
|
|
164
|
+
* instructions were unloaded without a word.
|
|
165
|
+
*
|
|
161
166
|
* The user's own files keep their order and come first; the kit's is
|
|
162
167
|
* appended. Duplicates are dropped, so re-running `use` does not grow the
|
|
163
168
|
* list.
|
|
@@ -167,7 +172,12 @@ export function utilityModel(p) {
|
|
|
167
172
|
* @returns {string[]}
|
|
168
173
|
*/
|
|
169
174
|
export function mergeInstructions(base, kitPath) {
|
|
170
|
-
const
|
|
175
|
+
const given = Array.isArray(base)
|
|
176
|
+
? base
|
|
177
|
+
: typeof base === "string"
|
|
178
|
+
? [base]
|
|
179
|
+
: []
|
|
180
|
+
const list = given.filter((x) => typeof x === "string")
|
|
171
181
|
/** @type {string[]} */
|
|
172
182
|
const out = []
|
|
173
183
|
for (const entry of [...list, kitPath]) {
|
|
@@ -235,6 +245,75 @@ export const BASH_ALLOWLIST = [
|
|
|
235
245
|
*/
|
|
236
246
|
export { BASH_DENYLIST } from "./denylist.js"
|
|
237
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The user's own bash rules, as a map the kit can merge into its own.
|
|
250
|
+
*
|
|
251
|
+
* The value has to be one of the three verbs opencode's schema defines. A
|
|
252
|
+
* rule the kit cannot express cannot work anyway, and writing it out
|
|
253
|
+
* unchanged would make the whole config invalid - which is a worse outcome
|
|
254
|
+
* than a rule the kit drops.
|
|
255
|
+
*
|
|
256
|
+
* A rule the kit drops is NOT dropped silently: the pattern and the verb it
|
|
257
|
+
* carried are returned in `dropped`, and the header of the generated file
|
|
258
|
+
* names them. The rule was the user's, and a file that comes back looking
|
|
259
|
+
* exactly as it did before is how a lost rule stays lost.
|
|
260
|
+
*
|
|
261
|
+
* `permission.bash` may also be a SINGLE verb, and a single verb is the
|
|
262
|
+
* blanket rule - every command that no other rule names. It used to be
|
|
263
|
+
* dropped on the floor with the rest of the non-object values, so a user
|
|
264
|
+
* who had `bash: "allow"` lost the blanket allow and got the kit's map
|
|
265
|
+
* instead.
|
|
266
|
+
*
|
|
267
|
+
* @param {any} base the config as it is on disk
|
|
268
|
+
* @returns {{ rules: Record<string, string>, dropped: string[] }}
|
|
269
|
+
*/
|
|
270
|
+
function userBashRules(base) {
|
|
271
|
+
const own = base?.permission?.bash
|
|
272
|
+
/** @type {Record<string, string>} */
|
|
273
|
+
const rules = {}
|
|
274
|
+
/** @type {string[]} */
|
|
275
|
+
const dropped = []
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Keep a rule the kit can write, or name the one it cannot.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} pattern
|
|
281
|
+
* @param {any} verb
|
|
282
|
+
*/
|
|
283
|
+
const keep = (pattern, verb) => {
|
|
284
|
+
if (verb === "allow" || verb === "ask" || verb === "deny") {
|
|
285
|
+
rules[pattern] = verb
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const said = typeof verb === "string" ? JSON.stringify(verb) : String(verb)
|
|
289
|
+
dropped.push(`permission.bash "${pattern}" -> ${said}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (own === undefined || own === null) return { rules, dropped }
|
|
293
|
+
|
|
294
|
+
if (typeof own === "string") {
|
|
295
|
+
keep("*", own)
|
|
296
|
+
return { rules, dropped }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Anything else that is not a map of rules cannot be merged, and it is
|
|
300
|
+
// named so the loss is not silent either.
|
|
301
|
+
if (typeof own !== "object" || Array.isArray(own)) {
|
|
302
|
+
dropped.push(
|
|
303
|
+
`permission.bash is ${Array.isArray(own) ? "a list" : String(own)}, which is not a rule the kit can write`,
|
|
304
|
+
)
|
|
305
|
+
return { rules, dropped }
|
|
306
|
+
}
|
|
307
|
+
for (const [pattern, verb] of Object.entries(own)) {
|
|
308
|
+
if (typeof pattern !== "string" || !pattern) {
|
|
309
|
+
dropped.push(`permission.bash has a rule with no pattern, which was dropped`)
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
keep(pattern, verb)
|
|
313
|
+
}
|
|
314
|
+
return { rules, dropped }
|
|
315
|
+
}
|
|
316
|
+
|
|
238
317
|
/**
|
|
239
318
|
* @param {Profile} p
|
|
240
319
|
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
|
|
@@ -294,9 +373,34 @@ export function generateConfig(p, opts = {}) {
|
|
|
294
373
|
agent[role] = existing
|
|
295
374
|
}
|
|
296
375
|
|
|
376
|
+
// The user's own bash rules are merged in, not replaced.
|
|
377
|
+
//
|
|
378
|
+
// The map used to be built from the kit's two lists alone, so every rule
|
|
379
|
+
// the user had written was deleted on the next `oc-harness use` - and the
|
|
380
|
+
// file that came back looked exactly as it had before, so nothing said
|
|
381
|
+
// it. The rules that let somebody run their own tools without a prompt
|
|
382
|
+
// were the ones that vanished.
|
|
383
|
+
//
|
|
384
|
+
// The order is the precedence ladder, and it is the kit's denylist that
|
|
385
|
+
// closes it:
|
|
386
|
+
//
|
|
387
|
+
// kit allowlist -> the user's rules -> kit denylist
|
|
388
|
+
//
|
|
389
|
+
// The denylist is last because a deny a user rule could re-allow would
|
|
390
|
+
// not be a deny, and the denylist is the mechanical half of the safety
|
|
391
|
+
// policy. The user's rules sit after the allowlist so a rule of their own
|
|
392
|
+
// is not shadowed by one of the kit's.
|
|
393
|
+
//
|
|
394
|
+
// The kit's own catch-all stays the FIRST key, so a user rule for `*`
|
|
395
|
+
// replaces its value and not its position: the catch-all remains the
|
|
396
|
+
// fallback for the commands no other rule mentions, and the allowlist
|
|
397
|
+
// still decides the ones it names. opencode takes the last matching rule,
|
|
398
|
+
// so this order is the whole rule.
|
|
399
|
+
const own = userBashRules(base)
|
|
297
400
|
const bashRules = {
|
|
298
401
|
"*": "ask",
|
|
299
402
|
...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
|
|
403
|
+
...own.rules,
|
|
300
404
|
...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
|
|
301
405
|
}
|
|
302
406
|
|
|
@@ -423,13 +527,19 @@ export function generateConfig(p, opts = {}) {
|
|
|
423
527
|
},
|
|
424
528
|
}
|
|
425
529
|
|
|
426
|
-
return render(out, p, {
|
|
530
|
+
return render(out, p, {
|
|
531
|
+
comments: opts.comments ?? true,
|
|
532
|
+
instructionsPath,
|
|
533
|
+
unhealthy,
|
|
534
|
+
dropped: own.dropped,
|
|
535
|
+
})
|
|
427
536
|
}
|
|
428
537
|
|
|
429
538
|
/**
|
|
430
539
|
* @param {Record<string, any>} cfg
|
|
431
540
|
* @param {Profile} p
|
|
432
|
-
* @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string
|
|
541
|
+
* @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
|
|
542
|
+
* dropped?: string[] }} opts
|
|
433
543
|
* @returns {string}
|
|
434
544
|
*/
|
|
435
545
|
function render(cfg, p, opts) {
|
|
@@ -481,6 +591,19 @@ function render(cfg, p, opts) {
|
|
|
481
591
|
`${c}ceiling ${p.maxRetention} - a lane may only resolve to a model at or below this`,
|
|
482
592
|
]
|
|
483
593
|
: []),
|
|
594
|
+
// The rules the kit could not carry over. They are named HERE, in the
|
|
595
|
+
// file the user reads, because a rule that is dropped without a word is
|
|
596
|
+
// a rule that is lost for good: the file comes back looking exactly as
|
|
597
|
+
// it did before, and the prompt it used to allow is back.
|
|
598
|
+
...(opts.dropped?.length
|
|
599
|
+
? [
|
|
600
|
+
`${c}`,
|
|
601
|
+
`${c}Dropped from your own permission.bash rules, and NOT in the file below.`,
|
|
602
|
+
`${c}The verb has to be one of allow, ask or deny - a rule the kit cannot`,
|
|
603
|
+
`${c}write would fail the schema for the whole file:`,
|
|
604
|
+
...opts.dropped.map((d) => `${c} ${d}`),
|
|
605
|
+
]
|
|
606
|
+
: []),
|
|
484
607
|
`${c}`,
|
|
485
608
|
`${c}Derived numbers, do not hand-edit:`,
|
|
486
609
|
`${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,
|
package/src/denylist.js
CHANGED
|
@@ -19,10 +19,50 @@ export const BASH_DENYLIST = [
|
|
|
19
19
|
// so opencode prompts for human approval before publishing.
|
|
20
20
|
"rm -rf *",
|
|
21
21
|
"rm -fr *",
|
|
22
|
+
// The short forms of the same flags, one at a time.
|
|
23
|
+
//
|
|
24
|
+
// The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
|
|
25
|
+
// cluster contains r or f in any case, and an rm whose long flag is
|
|
26
|
+
// --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
|
|
27
|
+
// there - and the permission map has to deny what the guard blocks, or
|
|
28
|
+
// the two layers contradict each other: the map would ask about a
|
|
29
|
+
// command the guard refuses.
|
|
30
|
+
//
|
|
31
|
+
// `-R` is named too because the guard's rule is case-insensitive and
|
|
32
|
+
// GNU rm reads `-R` as `--recursive`.
|
|
33
|
+
//
|
|
34
|
+
// The two layers are not the same matcher. The guard reads the tokens
|
|
35
|
+
// and can see a destructive flag in any position; this list is a flat
|
|
36
|
+
// glob over the command, so it can only name the spellings.
|
|
37
|
+
//
|
|
38
|
+
// A cluster a glob cannot express (`-rfv`) is caught by the guard and
|
|
39
|
+
// asked about by the config - the config asks about everything it does
|
|
40
|
+
// not name, and asking is the safe direction.
|
|
41
|
+
//
|
|
42
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
|
|
43
|
+
// the guard's rule needs the flag before the path, and the glob needs
|
|
44
|
+
// the flag in front. The config asks, so a human decides. Closing that
|
|
45
|
+
// hole is a change to the guard's rule in src/rules.js, which reads the
|
|
46
|
+
// tokens - a glob here could only name one spelling of it, and the
|
|
47
|
+
// spelling would still be the one it missed.
|
|
48
|
+
"rm -f *",
|
|
49
|
+
"rm -r *",
|
|
50
|
+
"rm -R *",
|
|
51
|
+
// The long GNU names of the same two flags. A delete that is spelled
|
|
52
|
+
// `--recursive --force` is the delete the short forms name.
|
|
53
|
+
"rm --recursive *",
|
|
54
|
+
"rm --force *",
|
|
22
55
|
"curl * | sh",
|
|
23
56
|
"curl * | bash",
|
|
57
|
+
// The same pipe into a shell that is named by its path. The shell is the
|
|
58
|
+
// same shell either way, and the config can only match what it can see.
|
|
59
|
+
"curl * | */sh",
|
|
60
|
+
"curl * | */bash",
|
|
24
61
|
"chmod 777 *",
|
|
25
62
|
"chmod 777*",
|
|
63
|
+
// The same mode written with a leading zero.
|
|
64
|
+
"chmod 0777 *",
|
|
65
|
+
"chmod 0777*",
|
|
26
66
|
"gh pr create*",
|
|
27
67
|
"gh pr merge*",
|
|
28
68
|
"gh pr close*",
|
|
@@ -52,6 +92,10 @@ export const BASH_DENYLIST = [
|
|
|
52
92
|
*
|
|
53
93
|
* `*` matches any run of characters, including none.
|
|
54
94
|
*
|
|
95
|
+
* Every other metacharacter is literal. `?` is a regex quantifier, so an
|
|
96
|
+
* unescaped one made the character before it optional and the pattern
|
|
97
|
+
* matched a command it never named.
|
|
98
|
+
*
|
|
55
99
|
* @param {string} command
|
|
56
100
|
* @param {string} pattern
|
|
57
101
|
* @returns {boolean}
|
|
@@ -59,7 +103,7 @@ export const BASH_DENYLIST = [
|
|
|
59
103
|
export function matchesPattern(command, pattern) {
|
|
60
104
|
const re = new RegExp(
|
|
61
105
|
"^" +
|
|
62
|
-
pattern.replace(/[.+^${}()|[\]
|
|
106
|
+
pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
|
|
63
107
|
"$",
|
|
64
108
|
)
|
|
65
109
|
return re.test(command)
|
|
@@ -78,7 +122,7 @@ export function matchesPattern(command, pattern) {
|
|
|
78
122
|
* same list and one of them reads it more carefully; that is the price of
|
|
79
123
|
* having the list in two places.
|
|
80
124
|
*
|
|
81
|
-
* The
|
|
125
|
+
* The PIPE patterns are for the config's side only. `segments()` splits
|
|
82
126
|
* on `|`, so a segment never contains " | sh" and the guard catches a piped
|
|
83
127
|
* script through its own rule instead.
|
|
84
128
|
*
|
package/src/guard.ts
CHANGED
|
@@ -31,12 +31,50 @@ export const MARKER = "[opencode-harness-kit] blocked"
|
|
|
31
31
|
*/
|
|
32
32
|
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether a hunk header follows, before another file's headers do.
|
|
36
|
+
*
|
|
37
|
+
* A `---`/`+++` pair is the header pair of a file only when a hunk header
|
|
38
|
+
* (`@@`) comes after it. That is the difference between the headers of a
|
|
39
|
+
* diff and two body lines that happen to look like one, and it is the same
|
|
40
|
+
* thing a reader of the diff does.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} lines
|
|
43
|
+
* @param {number} from the line to start looking at
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function hunkFollows(lines: string[], from: number): boolean {
|
|
47
|
+
for (let i = from; i < lines.length; i++) {
|
|
48
|
+
if (/^@@/.test(lines[i])) return true
|
|
49
|
+
// Another pair of headers starts here, so this pair was not followed by
|
|
50
|
+
// a hunk of its own.
|
|
51
|
+
if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* The files a patch touches.
|
|
36
58
|
*
|
|
37
59
|
* `apply_patch` sends a patch, not a path, so the paths have to be read out
|
|
38
60
|
* of it. Two formats are in use: opencode's own `*** Update File: path`
|
|
39
|
-
* markers, and a plain unified diff with `+++ b/path`
|
|
61
|
+
* markers, and a plain unified diff with `--- a/path` and `+++ b/path`
|
|
62
|
+
* headers.
|
|
63
|
+
*
|
|
64
|
+
* A unified diff names the file TWICE: `--- a/path` is where the change
|
|
65
|
+
* comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
|
|
66
|
+
* and `+++ /dev/null`, so the path that has to be scope-checked is the one
|
|
67
|
+
* in the `---` header and nowhere else. Reading only the `+++` header meant
|
|
68
|
+
* a patch that deleted a file outside the project was checked against
|
|
69
|
+
* nothing at all - and a patch that deletes a file is exactly the shape
|
|
70
|
+
* that reaches outside the project.
|
|
71
|
+
*
|
|
72
|
+
* The headers of a diff come BEFORE its first hunk, and a body line can
|
|
73
|
+
* look like a header: a removed line whose content is "-- foo" renders as
|
|
74
|
+
* "--- foo", and an added line "++ bar" renders as "+++ bar". Reading
|
|
75
|
+
* those as the headers of a file collected two words as paths, and a path
|
|
76
|
+
* that is not a path is a write the guard refuses for a reason nobody can
|
|
77
|
+
* find.
|
|
40
78
|
*
|
|
41
79
|
* @param {string} text
|
|
42
80
|
* @returns {string[]}
|
|
@@ -44,16 +82,49 @@ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
|
|
|
44
82
|
export function pathsInPatch(text: string): string[] {
|
|
45
83
|
const out: string[] = []
|
|
46
84
|
const push = (p: string) => {
|
|
47
|
-
|
|
85
|
+
// A unified diff header can end in a timestamp: `--- a/file\t2026-01-01`.
|
|
86
|
+
const v = p.split("\t")[0].trim()
|
|
48
87
|
if (!v) return
|
|
49
88
|
if (v === "/dev/null") return
|
|
50
89
|
if (!out.includes(v)) out.push(v)
|
|
51
90
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
const lines = text.split("\n")
|
|
92
|
+
// Whether a hunk header has been seen. After one, a `+++` line is added
|
|
93
|
+
// content and not the header of another file.
|
|
94
|
+
let seenHunk = false
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const line = lines[i]
|
|
97
|
+
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
|
|
98
|
+
if (marker) {
|
|
99
|
+
push(marker[1])
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (/^@@/.test(line)) {
|
|
103
|
+
seenHunk = true
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// The `---` and `+++` headers of a unified diff are one pair: the
|
|
107
|
+
// first names where the change comes FROM, the second where it goes
|
|
108
|
+
// TO. They are read together so a body line that looks like a header
|
|
109
|
+
// is not mistaken for one.
|
|
110
|
+
const minus = /^---\s+(?:a\/)?(.+)$/.exec(line)
|
|
111
|
+
const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null
|
|
112
|
+
if (minus && next) {
|
|
113
|
+
// A pair is a header pair only when a hunk header follows it.
|
|
114
|
+
if (hunkFollows(lines, i + 2)) {
|
|
115
|
+
push(minus[1])
|
|
116
|
+
push(next[1])
|
|
117
|
+
}
|
|
118
|
+
// The `+++` line is consumed either way: it is the other half of the
|
|
119
|
+
// pair, and not a header of its own.
|
|
120
|
+
i++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
// A diff that carries only the `+++` header still names its file. It
|
|
124
|
+
// only names one while no hunk has been seen - after a hunk, a `+++`
|
|
125
|
+
// line is added content.
|
|
126
|
+
const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line)
|
|
127
|
+
if (plus && !seenHunk) push(plus[1])
|
|
57
128
|
}
|
|
58
129
|
return out
|
|
59
130
|
}
|