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/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 join8 } from "node:path";
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 join3, resolve as resolve2 } from "node:path";
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 a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget;
113
- const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot;
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 GIT_WRAPPERS = /* @__PURE__ */ new Set([
262
- "env",
263
- "sudo",
264
- "doas",
265
- "nohup",
266
- "time",
267
- "command",
268
- "exec",
269
- "xargs",
270
- "npx"
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
- if (GIT_WRAPPERS.has(baseName(word))) {
335
- i++;
336
- while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
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 join(dir, "telemetry.jsonl");
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 join2, resolve } from "node:path";
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(join2(dir, ".git"))) return dir;
679
- const parent = dirname(dir);
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 = join3(project, ".space");
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("~") ? join3(homedir2(), raw.slice(1)) : resolve2(project, raw);
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 dirname2, join as join4, resolve as resolve3 } from "node:path";
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 = dirname2(fileURLToPath(import.meta.url));
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(join4(dir, "profiles"))) return dir;
844
- const parent = dirname2(dir);
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 ?? join4(ROOT, "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 = join4(dir, `${id}.json`);
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 = join4(ROOT, "lanes.json");
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 = join4(ROOT, "retention.yml");
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 ?? join4(homedir3(), ".config", "opencode");
1550
+ return process.env.OPENCODE_CONFIG_DIR ?? join5(homedir3(), ".config", "opencode");
1362
1551
  }
1363
1552
  function configFilePath() {
1364
- return join4(configDir(), "opencode.jsonc");
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 join5 } from "node:path";
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 = [join5(worktree, ".space", "active")];
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 = join5(worktree, ".space");
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
- join5(dir, "prune.jsonl"),
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 join6 } from "node:path";
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 join6(dir, ".space", "health.json");
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(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
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 join7 } from "node:path";
2181
+ import { join as join8 } from "node:path";
1993
2182
  var TelemetryPlugin = async ({ worktree, directory }) => {
1994
- const dir = join7(projectRoot(directory, worktree), ".space");
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 = join8(project, ".space");
2364
+ const dir = join9(project, ".space");
2176
2365
  mkdirSync3(dir, { recursive: true });
2177
- writeFileSync2(join8(dir, "active"), `${id}
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
- "*": "ask",
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
- "*": "ask",
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.7",
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
- const c = opts.comments ? "// " : ""
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()