opencode-harness-kit 0.5.7 → 0.5.8
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 +191 -0
- package/README.md +34 -0
- package/bin/baseline.mjs +16 -1
- package/bin/oc-harness.mjs +353 -34
- package/dist/index.js +240 -51
- package/dist/rules.d.ts +6 -0
- package/lanes.json +2 -2
- package/package.json +1 -1
- package/src/config.js +9 -1
- package/src/rules.js +303 -21
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3
|
-
import { join as
|
|
3
|
+
import { join as join9 } from "node:path";
|
|
4
4
|
|
|
5
5
|
// src/guard.ts
|
|
6
6
|
import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join4, resolve as resolve2 } from "node:path";
|
|
8
8
|
|
|
9
9
|
// src/rules.js
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
11
|
-
import { resolve as resolvePath, sep } from "node:path";
|
|
11
|
+
import { resolve as resolvePath, sep, dirname, basename, join } from "node:path";
|
|
12
|
+
import { realpathSync } from "node:fs";
|
|
12
13
|
|
|
13
14
|
// src/denylist.js
|
|
14
15
|
var BASH_DENYLIST = [
|
|
@@ -104,13 +105,31 @@ function expandHome(p) {
|
|
|
104
105
|
if (p.startsWith("~/")) return resolvePath(homedir(), p.slice(2));
|
|
105
106
|
return p;
|
|
106
107
|
}
|
|
108
|
+
function realpathOfNearestExisting(p) {
|
|
109
|
+
const missing = [];
|
|
110
|
+
let current = p;
|
|
111
|
+
for (; ; ) {
|
|
112
|
+
try {
|
|
113
|
+
const real = realpathSync(current);
|
|
114
|
+
return missing.length ? join(real, ...missing) : real;
|
|
115
|
+
} catch {
|
|
116
|
+
}
|
|
117
|
+
const parent = dirname(current);
|
|
118
|
+
if (parent === current) return current;
|
|
119
|
+
missing.unshift(basename(current));
|
|
120
|
+
current = parent;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
107
123
|
function insideRoot(target, root) {
|
|
108
124
|
if (!root) return true;
|
|
109
125
|
const absTarget = resolvePath(root, expandHome(target));
|
|
110
126
|
const absRoot = resolvePath(root);
|
|
111
127
|
if (absTarget === absRoot) return true;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
128
|
+
const realTarget = realpathOfNearestExisting(absTarget);
|
|
129
|
+
const realRoot = realpathOfNearestExisting(absRoot);
|
|
130
|
+
if (realTarget === realRoot) return true;
|
|
131
|
+
const a = CASE_INSENSITIVE_FS ? realTarget.toLowerCase() : realTarget;
|
|
132
|
+
const b = CASE_INSENSITIVE_FS ? realRoot.toLowerCase() : realRoot;
|
|
114
133
|
return a.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
115
134
|
}
|
|
116
135
|
var HEREDOC = /^<<(-)?(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/;
|
|
@@ -258,21 +277,84 @@ function tokenize(segment) {
|
|
|
258
277
|
return tokens;
|
|
259
278
|
}
|
|
260
279
|
var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
261
|
-
var
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
280
|
+
var WRAPPERS = {
|
|
281
|
+
env: /* @__PURE__ */ new Set([
|
|
282
|
+
"-u",
|
|
283
|
+
"--unset",
|
|
284
|
+
"-C",
|
|
285
|
+
"--chdir",
|
|
286
|
+
"-S",
|
|
287
|
+
"--split-string",
|
|
288
|
+
"--argv0",
|
|
289
|
+
"--block-signal",
|
|
290
|
+
"--default-signal",
|
|
291
|
+
"--ignore-signal"
|
|
292
|
+
]),
|
|
293
|
+
sudo: /* @__PURE__ */ new Set([
|
|
294
|
+
"-u",
|
|
295
|
+
"--user",
|
|
296
|
+
"-g",
|
|
297
|
+
"--group",
|
|
298
|
+
"-h",
|
|
299
|
+
"--host",
|
|
300
|
+
"-p",
|
|
301
|
+
"--prompt",
|
|
302
|
+
"-C",
|
|
303
|
+
"--close-from",
|
|
304
|
+
"-T",
|
|
305
|
+
"--command-timeout",
|
|
306
|
+
"-R",
|
|
307
|
+
"--chroot",
|
|
308
|
+
"-D",
|
|
309
|
+
"--chdir",
|
|
310
|
+
"--other-user"
|
|
311
|
+
]),
|
|
312
|
+
doas: /* @__PURE__ */ new Set(["-u", "--user"]),
|
|
313
|
+
nohup: /* @__PURE__ */ new Set(),
|
|
314
|
+
time: /* @__PURE__ */ new Set(["-f", "--file", "-o", "--format", "-p", "--portability"]),
|
|
315
|
+
command: /* @__PURE__ */ new Set(["-p", "--path", "-v", "--verbose"]),
|
|
316
|
+
exec: /* @__PURE__ */ new Set(["-a", "--argv0", "-c", "--command", "-l", "--login"]),
|
|
317
|
+
xargs: /* @__PURE__ */ new Set([
|
|
318
|
+
"-a",
|
|
319
|
+
"--arg-file",
|
|
320
|
+
"-d",
|
|
321
|
+
"--delimiter",
|
|
322
|
+
"-E",
|
|
323
|
+
"--eof",
|
|
324
|
+
"-I",
|
|
325
|
+
"--replace",
|
|
326
|
+
"-L",
|
|
327
|
+
"--max-lines",
|
|
328
|
+
"-n",
|
|
329
|
+
"--max-args",
|
|
330
|
+
"-P",
|
|
331
|
+
"--max-procs",
|
|
332
|
+
"-s",
|
|
333
|
+
"--max-chars",
|
|
334
|
+
"-t",
|
|
335
|
+
"--verbose",
|
|
336
|
+
"-x",
|
|
337
|
+
"--exit-on-error"
|
|
338
|
+
]),
|
|
339
|
+
npx: /* @__PURE__ */ new Set(["-p", "--package", "-c", "--call", "--registry"]),
|
|
340
|
+
// The wrappers that run a command and are not in the list above are the
|
|
341
|
+
// ones that were missing from it, and every one of them was a way around
|
|
342
|
+
// the rules.
|
|
343
|
+
timeout: /* @__PURE__ */ new Set(["-k", "--kill-after", "-s", "--signal"]),
|
|
344
|
+
nice: /* @__PURE__ */ new Set(["-n", "--adjustment"]),
|
|
345
|
+
ionice: /* @__PURE__ */ new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid", "-t"]),
|
|
346
|
+
stdbuf: /* @__PURE__ */ new Set(["-i", "--input", "-o", "--output", "-e", "--error"]),
|
|
347
|
+
setsid: /* @__PURE__ */ new Set(),
|
|
348
|
+
chroot: /* @__PURE__ */ new Set(["-u", "--users", "-g", "--groups", "--userspec", "--groupspec"]),
|
|
349
|
+
taskset: /* @__PURE__ */ new Set(["-c", "--cpu-list", "-p", "--pid"]),
|
|
350
|
+
busybox: /* @__PURE__ */ new Set()
|
|
351
|
+
};
|
|
352
|
+
var WRAPPER_TABLE = WRAPPERS;
|
|
272
353
|
var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
273
354
|
var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
|
|
274
355
|
var DOWNLOADERS = /\b(curl|wget)\b/i;
|
|
275
356
|
var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
|
|
357
|
+
var DURATION = /^\d+(\.\d+)?[smhd]?$/;
|
|
276
358
|
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
277
359
|
"-C",
|
|
278
360
|
"-c",
|
|
@@ -322,6 +404,17 @@ function baseName(word) {
|
|
|
322
404
|
const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
|
|
323
405
|
return slash === -1 ? word : word.slice(slash + 1);
|
|
324
406
|
}
|
|
407
|
+
function skipWrapperArgs(tokens, i, name) {
|
|
408
|
+
const valueFlags = WRAPPER_TABLE[name] ?? WRAPPER_VALUE_FLAGS;
|
|
409
|
+
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
410
|
+
const flag = tokens[i];
|
|
411
|
+
i++;
|
|
412
|
+
if (valueFlags.has(flag) && !flag.includes("=") && i < tokens.length) i++;
|
|
413
|
+
}
|
|
414
|
+
if (name === "timeout" && i < tokens.length && DURATION.test(tokens[i])) i++;
|
|
415
|
+
if (name === "chroot" && i < tokens.length && !tokens[i].startsWith("-")) i++;
|
|
416
|
+
return i;
|
|
417
|
+
}
|
|
325
418
|
function skipPrefixes(tokens) {
|
|
326
419
|
let i = 0;
|
|
327
420
|
for (; ; ) {
|
|
@@ -331,13 +424,9 @@ function skipPrefixes(tokens) {
|
|
|
331
424
|
i++;
|
|
332
425
|
continue;
|
|
333
426
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const flag = tokens[i];
|
|
338
|
-
i++;
|
|
339
|
-
if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++;
|
|
340
|
-
}
|
|
427
|
+
const name = baseName(word);
|
|
428
|
+
if (name in WRAPPER_TABLE) {
|
|
429
|
+
i = skipWrapperArgs(tokens, i + 1, name);
|
|
341
430
|
continue;
|
|
342
431
|
}
|
|
343
432
|
return i;
|
|
@@ -375,6 +464,10 @@ function gitSubcommand(segment) {
|
|
|
375
464
|
i++;
|
|
376
465
|
while (i < tokens.length) {
|
|
377
466
|
const flag = tokens[i];
|
|
467
|
+
if (flag.length > 2 && !flag.startsWith("--") && (flag.startsWith("-C") || flag.startsWith("-c"))) {
|
|
468
|
+
i++;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
378
471
|
if (GIT_VALUE_FLAGS.has(flag)) {
|
|
379
472
|
i += 2;
|
|
380
473
|
continue;
|
|
@@ -400,9 +493,39 @@ function shellCommandString(tokens, from) {
|
|
|
400
493
|
}
|
|
401
494
|
return null;
|
|
402
495
|
}
|
|
496
|
+
function envSplitPayload(tokens) {
|
|
497
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
498
|
+
const word = tokens[i];
|
|
499
|
+
if (word === void 0) return null;
|
|
500
|
+
if (ENV_ASSIGN.test(word)) continue;
|
|
501
|
+
const name = baseName(word);
|
|
502
|
+
if (name === "env") {
|
|
503
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
504
|
+
const token = tokens[j];
|
|
505
|
+
if (token === "-S" || token === "--split-string") {
|
|
506
|
+
return tokens[j + 1] ?? null;
|
|
507
|
+
}
|
|
508
|
+
if (token.startsWith("--split-string=")) {
|
|
509
|
+
return token.slice("--split-string=".length) || null;
|
|
510
|
+
}
|
|
511
|
+
if (token.startsWith("-S") && token.length > 2) {
|
|
512
|
+
return token.slice(2) || null;
|
|
513
|
+
}
|
|
514
|
+
if (ENV_ASSIGN.test(token)) continue;
|
|
515
|
+
if (!token.startsWith("-")) break;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
if (!(name in WRAPPER_TABLE)) return null;
|
|
520
|
+
i = skipWrapperArgs(tokens, i + 1, name) - 1;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
403
524
|
function shellPayload(segment) {
|
|
404
525
|
const tokens = tokenize(segment);
|
|
405
526
|
if (tokens.length < 2) return null;
|
|
527
|
+
const split2 = envSplitPayload(tokens);
|
|
528
|
+
if (split2) return split2;
|
|
406
529
|
const i = skipPrefixes(tokens);
|
|
407
530
|
if (i >= tokens.length) return null;
|
|
408
531
|
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
|
|
@@ -488,6 +611,57 @@ function opensPermissions(text) {
|
|
|
488
611
|
return /^0*777$/.test(mode);
|
|
489
612
|
}
|
|
490
613
|
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
614
|
+
function downloadTarget(segment) {
|
|
615
|
+
const tokens = tokenize(segment);
|
|
616
|
+
const i = skipPrefixes(tokens);
|
|
617
|
+
if (i >= tokens.length) return null;
|
|
618
|
+
const name = baseName(tokens[i]).toLowerCase();
|
|
619
|
+
if (name !== "curl" && name !== "wget") return null;
|
|
620
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
621
|
+
const token = tokens[j];
|
|
622
|
+
if (token === "--") break;
|
|
623
|
+
if (token === "-o" || token === "--output" || token === "-O" || token === "--output-document") {
|
|
624
|
+
const value = tokens[j + 1];
|
|
625
|
+
return value && !value.startsWith("-") ? value : null;
|
|
626
|
+
}
|
|
627
|
+
if (token.startsWith("--output=") || token.startsWith("--output-document=")) {
|
|
628
|
+
return token.slice(token.indexOf("=") + 1) || null;
|
|
629
|
+
}
|
|
630
|
+
if (name === "wget" && token.startsWith("-O") && token.length > 2) {
|
|
631
|
+
return token.slice(2) || null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
function sameFile(a, b) {
|
|
637
|
+
if (a === b) return true;
|
|
638
|
+
if (!a.includes("/") || !b.includes("/")) return baseName(a) === baseName(b);
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
function runsFile(segment, file) {
|
|
642
|
+
const tokens = tokenize(segment);
|
|
643
|
+
const i = skipPrefixes(tokens);
|
|
644
|
+
if (i >= tokens.length) return false;
|
|
645
|
+
if (sameFile(tokens[i], file)) return true;
|
|
646
|
+
const name = baseName(tokens[i]);
|
|
647
|
+
const runner = SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name) || name === "source" || name === ".";
|
|
648
|
+
if (!runner) return false;
|
|
649
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
650
|
+
if (sameFile(tokens[j], file)) return true;
|
|
651
|
+
}
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
function downloadsAndRuns(command) {
|
|
655
|
+
const parts = segments(command);
|
|
656
|
+
for (let i = 0; i < parts.length; i++) {
|
|
657
|
+
const file = downloadTarget(parts[i]);
|
|
658
|
+
if (!file) continue;
|
|
659
|
+
for (let j = i + 1; j < parts.length; j++) {
|
|
660
|
+
if (runsFile(parts[j], file)) return true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
491
665
|
var BASH_RULES = [
|
|
492
666
|
{
|
|
493
667
|
name: "secret exfiltration via shell",
|
|
@@ -519,6 +693,16 @@ var BASH_RULES = [
|
|
|
519
693
|
check: (text) => pipesScriptIntoShell(text),
|
|
520
694
|
why: "downloads and executes a script in one step"
|
|
521
695
|
},
|
|
696
|
+
{
|
|
697
|
+
name: "download and run",
|
|
698
|
+
scope: "command",
|
|
699
|
+
// The pipe rule above is the same download in ONE step. This is the
|
|
700
|
+
// same download in TWO: fetch it to a file, then run the file. The
|
|
701
|
+
// rules only ever looked at a pipe, so the two-step form was ordinary
|
|
702
|
+
// work - and it runs exactly the same downloaded script.
|
|
703
|
+
check: (text) => downloadsAndRuns(text),
|
|
704
|
+
why: "downloads a script to a file and then runs it"
|
|
705
|
+
},
|
|
522
706
|
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
523
707
|
// config), not guard-blocked. Publishing needs human approval, but the
|
|
524
708
|
// guard lets it through so opencode can prompt first.
|
|
@@ -575,6 +759,11 @@ var WRITE_RULES = [
|
|
|
575
759
|
{
|
|
576
760
|
name: "write to a private key",
|
|
577
761
|
test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
|
|
762
|
+
// The PUBLIC half of a key pair is a file the user is meant to publish.
|
|
763
|
+
// The rule named the key FILES, and `id_rsa.pub` is one of them, so
|
|
764
|
+
// writing a public key - the ordinary thing to do with a key - was
|
|
765
|
+
// refused for a reason nobody could find.
|
|
766
|
+
unless: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\.pub(\.[^/\\]*)?$/i,
|
|
578
767
|
why: "private key material"
|
|
579
768
|
}
|
|
580
769
|
];
|
|
@@ -642,10 +831,10 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
|
642
831
|
|
|
643
832
|
// src/telemetry-store.ts
|
|
644
833
|
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
|
|
645
|
-
import { join } from "node:path";
|
|
834
|
+
import { join as join2 } from "node:path";
|
|
646
835
|
var MAX_BYTES = 4 * 1024 * 1024;
|
|
647
836
|
function logPath(dir) {
|
|
648
|
-
return
|
|
837
|
+
return join2(dir, "telemetry.jsonl");
|
|
649
838
|
}
|
|
650
839
|
function rotate(file, maxBytes = MAX_BYTES) {
|
|
651
840
|
try {
|
|
@@ -670,13 +859,13 @@ function write(dir, record) {
|
|
|
670
859
|
|
|
671
860
|
// src/project.js
|
|
672
861
|
import { existsSync as existsSync2 } from "node:fs";
|
|
673
|
-
import { dirname, join as
|
|
862
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
674
863
|
function projectRoot(directory, worktree) {
|
|
675
864
|
const start = resolve(directory || worktree || ".");
|
|
676
865
|
let dir = start;
|
|
677
866
|
for (let i = 0; i < 64; i++) {
|
|
678
|
-
if (existsSync2(
|
|
679
|
-
const parent =
|
|
867
|
+
if (existsSync2(join3(dir, ".git"))) return dir;
|
|
868
|
+
const parent = dirname2(dir);
|
|
680
869
|
if (parent === dir) break;
|
|
681
870
|
dir = parent;
|
|
682
871
|
}
|
|
@@ -759,7 +948,7 @@ function truncate(s, n = 200) {
|
|
|
759
948
|
}
|
|
760
949
|
var GuardPlugin = async ({ worktree, directory }) => {
|
|
761
950
|
const project = projectRoot(directory, worktree);
|
|
762
|
-
const telemetryDir =
|
|
951
|
+
const telemetryDir = join4(project, ".space");
|
|
763
952
|
const refuse = (tool, rule, why, detail) => {
|
|
764
953
|
write(telemetryDir, { kind: "guard", tool, rule });
|
|
765
954
|
deny(why, detail);
|
|
@@ -770,7 +959,7 @@ var GuardPlugin = async ({ worktree, directory }) => {
|
|
|
770
959
|
if (input.tool === "bash") {
|
|
771
960
|
const raw = output.args?.workdir;
|
|
772
961
|
if (typeof raw === "string" && raw.trim()) {
|
|
773
|
-
const target = raw.startsWith("~") ?
|
|
962
|
+
const target = raw.startsWith("~") ? join4(homedir2(), raw.slice(1)) : resolve2(project, raw);
|
|
774
963
|
if (!insideRoot(target, project) && !insideRoot(target, tmpdir2())) {
|
|
775
964
|
refuse(
|
|
776
965
|
input.tool,
|
|
@@ -833,15 +1022,15 @@ var GuardPlugin = async ({ worktree, directory }) => {
|
|
|
833
1022
|
|
|
834
1023
|
// src/profile.js
|
|
835
1024
|
import { readFileSync, existsSync as existsSync3, readdirSync } from "node:fs";
|
|
836
|
-
import { dirname as
|
|
1025
|
+
import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
|
|
837
1026
|
import { homedir as homedir3 } from "node:os";
|
|
838
1027
|
import { fileURLToPath } from "node:url";
|
|
839
|
-
var HERE =
|
|
1028
|
+
var HERE = dirname3(fileURLToPath(import.meta.url));
|
|
840
1029
|
function findRoot(start) {
|
|
841
1030
|
let dir = start;
|
|
842
1031
|
for (let i = 0; i < 12; i++) {
|
|
843
|
-
if (existsSync3(
|
|
844
|
-
const parent =
|
|
1032
|
+
if (existsSync3(join5(dir, "profiles"))) return dir;
|
|
1033
|
+
const parent = dirname3(dir);
|
|
845
1034
|
if (parent === dir) break;
|
|
846
1035
|
dir = parent;
|
|
847
1036
|
}
|
|
@@ -870,11 +1059,11 @@ function retentionSeverity(level) {
|
|
|
870
1059
|
);
|
|
871
1060
|
}
|
|
872
1061
|
function profileDirs() {
|
|
873
|
-
return [process.env.OPENCODE_HARNESS_PROFILES ??
|
|
1062
|
+
return [process.env.OPENCODE_HARNESS_PROFILES ?? join5(ROOT, "profiles")];
|
|
874
1063
|
}
|
|
875
1064
|
function profilePath(id) {
|
|
876
1065
|
for (const dir of profileDirs()) {
|
|
877
|
-
const full =
|
|
1066
|
+
const full = join5(dir, `${id}.json`);
|
|
878
1067
|
if (existsSync3(full)) return full;
|
|
879
1068
|
}
|
|
880
1069
|
return null;
|
|
@@ -922,7 +1111,7 @@ function normalize(p) {
|
|
|
922
1111
|
var laneCache = null;
|
|
923
1112
|
function loadLanes() {
|
|
924
1113
|
if (laneCache) return laneCache;
|
|
925
|
-
const file =
|
|
1114
|
+
const file = join5(ROOT, "lanes.json");
|
|
926
1115
|
if (!existsSync3(file)) {
|
|
927
1116
|
throw new ProfileError(`lane table missing: ${file}`);
|
|
928
1117
|
}
|
|
@@ -945,7 +1134,7 @@ function laneMeta(name) {
|
|
|
945
1134
|
var retentionCache = null;
|
|
946
1135
|
function loadRetention() {
|
|
947
1136
|
if (retentionCache) return retentionCache;
|
|
948
|
-
const file =
|
|
1137
|
+
const file = join5(ROOT, "retention.yml");
|
|
949
1138
|
if (!existsSync3(file)) {
|
|
950
1139
|
retentionCache = { models: {}, defaults: {} };
|
|
951
1140
|
return retentionCache;
|
|
@@ -1358,10 +1547,10 @@ function worstRetention(ids) {
|
|
|
1358
1547
|
return { retention, worst };
|
|
1359
1548
|
}
|
|
1360
1549
|
function configDir() {
|
|
1361
|
-
return process.env.OPENCODE_CONFIG_DIR ??
|
|
1550
|
+
return process.env.OPENCODE_CONFIG_DIR ?? join5(homedir3(), ".config", "opencode");
|
|
1362
1551
|
}
|
|
1363
1552
|
function configFilePath() {
|
|
1364
|
-
return
|
|
1553
|
+
return join5(configDir(), "opencode.jsonc");
|
|
1365
1554
|
}
|
|
1366
1555
|
function parseJsonc(text) {
|
|
1367
1556
|
let out = "";
|
|
@@ -1452,11 +1641,11 @@ function profileIdFromConfig() {
|
|
|
1452
1641
|
|
|
1453
1642
|
// src/prune.ts
|
|
1454
1643
|
import { readFileSync as readFileSync2, existsSync as existsSync4 } from "node:fs";
|
|
1455
|
-
import { join as
|
|
1644
|
+
import { join as join6 } from "node:path";
|
|
1456
1645
|
function activeProfileId(worktree) {
|
|
1457
1646
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
1458
1647
|
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
1459
|
-
const candidates = [
|
|
1648
|
+
const candidates = [join6(worktree, ".space", "active")];
|
|
1460
1649
|
for (const f of candidates) {
|
|
1461
1650
|
if (!f) continue;
|
|
1462
1651
|
try {
|
|
@@ -1655,12 +1844,12 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1655
1844
|
};
|
|
1656
1845
|
};
|
|
1657
1846
|
async function logPrune(worktree, profileId, stats) {
|
|
1658
|
-
const dir =
|
|
1847
|
+
const dir = join6(worktree, ".space");
|
|
1659
1848
|
try {
|
|
1660
1849
|
const { mkdir, appendFile } = await import("node:fs/promises");
|
|
1661
1850
|
await mkdir(dir, { recursive: true });
|
|
1662
1851
|
await appendFile(
|
|
1663
|
-
|
|
1852
|
+
join6(dir, "prune.jsonl"),
|
|
1664
1853
|
JSON.stringify({
|
|
1665
1854
|
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1666
1855
|
profile: profileId,
|
|
@@ -1696,7 +1885,7 @@ function utilityModel(p) {
|
|
|
1696
1885
|
|
|
1697
1886
|
// src/health.js
|
|
1698
1887
|
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
1699
|
-
import { join as
|
|
1888
|
+
import { join as join7 } from "node:path";
|
|
1700
1889
|
var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1701
1890
|
function spentWindowMs() {
|
|
1702
1891
|
const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
|
|
@@ -1726,7 +1915,7 @@ function liveUnhealthy(health, now = Date.now()) {
|
|
|
1726
1915
|
return out;
|
|
1727
1916
|
}
|
|
1728
1917
|
function healthPath(dir) {
|
|
1729
|
-
return
|
|
1918
|
+
return join7(dir, ".space", "health.json");
|
|
1730
1919
|
}
|
|
1731
1920
|
function readHealth(dir) {
|
|
1732
1921
|
let parsed;
|
|
@@ -1746,7 +1935,7 @@ function readHealth(dir) {
|
|
|
1746
1935
|
}
|
|
1747
1936
|
function writeHealth(dir, health) {
|
|
1748
1937
|
try {
|
|
1749
|
-
if (!existsSync5(
|
|
1938
|
+
if (!existsSync5(join7(dir, ".space"))) mkdirSync2(join7(dir, ".space"), { recursive: true });
|
|
1750
1939
|
const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
|
|
1751
1940
|
writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
|
|
1752
1941
|
} catch {
|
|
@@ -1989,9 +2178,9 @@ function applySessionModel(cfg, profile, opts = {}) {
|
|
|
1989
2178
|
}
|
|
1990
2179
|
|
|
1991
2180
|
// src/telemetry.ts
|
|
1992
|
-
import { join as
|
|
2181
|
+
import { join as join8 } from "node:path";
|
|
1993
2182
|
var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
1994
|
-
const dir =
|
|
2183
|
+
const dir = join8(projectRoot(directory, worktree), ".space");
|
|
1995
2184
|
const started = Date.now();
|
|
1996
2185
|
const opened = /* @__PURE__ */ new Set();
|
|
1997
2186
|
const closed = /* @__PURE__ */ new Set();
|
|
@@ -2172,9 +2361,9 @@ var HarnessPlugin = async (ctx, options) => {
|
|
|
2172
2361
|
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
|
|
2173
2362
|
sessionModel
|
|
2174
2363
|
});
|
|
2175
|
-
const dir =
|
|
2364
|
+
const dir = join9(project, ".space");
|
|
2176
2365
|
mkdirSync3(dir, { recursive: true });
|
|
2177
|
-
writeFileSync2(
|
|
2366
|
+
writeFileSync2(join9(dir, "active"), `${id}
|
|
2178
2367
|
`);
|
|
2179
2368
|
} catch {
|
|
2180
2369
|
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Whether a write target stays inside the project. A relative path is resolved
|
|
3
3
|
* against the project root, which is where the agent's working directory is.
|
|
4
|
+
*
|
|
5
|
+
* The comparison is made on the REAL path, not on the text of the path. A
|
|
6
|
+
* symlink inside the project that points at /etc is a path inside the
|
|
7
|
+
* project, and it is a write outside it - and the text of the path is the
|
|
8
|
+
* only thing the lexical comparison ever saw.
|
|
9
|
+
*
|
|
4
10
|
* @param {string} target
|
|
5
11
|
* @param {string} root
|
|
6
12
|
* @returns {boolean}
|
package/lanes.json
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"permission": {
|
|
70
70
|
"edit": "deny",
|
|
71
71
|
"bash": {
|
|
72
|
-
"*": "
|
|
72
|
+
"*": "allow",
|
|
73
73
|
"git status *": "allow",
|
|
74
74
|
"git diff *": "allow",
|
|
75
75
|
"mkdir*": "allow",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"permission": {
|
|
103
103
|
"edit": "deny",
|
|
104
104
|
"bash": {
|
|
105
|
-
"*": "
|
|
105
|
+
"*": "allow",
|
|
106
106
|
"pdftotext*": "allow",
|
|
107
107
|
"python3 -c*": "allow",
|
|
108
108
|
"cat*": "allow",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
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
|
@@ -543,7 +543,15 @@ export function generateConfig(p, opts = {}) {
|
|
|
543
543
|
* @returns {string}
|
|
544
544
|
*/
|
|
545
545
|
function render(cfg, p, opts) {
|
|
546
|
-
|
|
546
|
+
// `comments: false` means "no comments", and the whole header IS comments.
|
|
547
|
+
// Emitting it without the `// ` prefix - which is what this used to do -
|
|
548
|
+
// produced a file that was neither JSON nor JSONC: the header sat there as
|
|
549
|
+
// bare text in front of the JSON body, and the kit's own `parseJsonc`
|
|
550
|
+
// returned null for it. A caller that asked for pure JSON got a file
|
|
551
|
+
// nothing could read.
|
|
552
|
+
if (!opts.comments) return `${JSON.stringify(cfg, null, 2)}\n`
|
|
553
|
+
|
|
554
|
+
const c = "// "
|
|
547
555
|
const instructionsPath = opts.instructionsPath ?? globalInstructionsPath()
|
|
548
556
|
const unhealthy = opts.unhealthy
|
|
549
557
|
const lanes = loadLanes()
|