freeharness 0.4.2 → 0.4.3

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.
Files changed (54) hide show
  1. package/NOTICE +2 -1
  2. package/README.md +20 -16
  3. package/canonical/HARVEST.yaml +2 -2
  4. package/canonical/PROVENANCE.yaml +2 -2
  5. package/canonical/agents/security-reviewer.md +8 -2
  6. package/canonical/hooks/freeharness-config-guard.mjs +644 -4567
  7. package/canonical/hooks/freeharness-cost-tracker.mjs +14 -27
  8. package/canonical/hooks/freeharness-precompact.mjs +12 -23
  9. package/canonical/hooks/freeharness-sdd-context.mjs +12 -23
  10. package/canonical/hooks/hooks.config-guard.claude-code.json +2 -3
  11. package/canonical/hooks/hooks.memory-guard.claude-code.json +2 -3
  12. package/canonical/skills/code-review/SKILL.md +2 -2
  13. package/canonical/skills/code-review/evals/cases.yaml +1 -1
  14. package/canonical/skills/subagent-driven-development/SKILL.md +4 -4
  15. package/canonical/skills/subagent-driven-development/evals/cases.yaml +149 -20
  16. package/canonical/skills/subagent-driven-development/reference/brief.md +15 -13
  17. package/canonical/skills/subagent-driven-development/reference/handoff.md +29 -15
  18. package/canonical/skills/subagent-driven-development/reference/loop.md +4 -5
  19. package/canonical/skills/subagent-driven-development/reference/workspace.md +2 -2
  20. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +301 -1038
  21. package/dist/adapters/harness/claude-code.d.ts +7 -0
  22. package/dist/adapters/harness/claude-code.js +27 -21
  23. package/dist/adapters/harness/opencode.js +1 -1
  24. package/dist/adapters/harness/plugin.js +9 -9
  25. package/dist/adapters/memory/doctor.d.ts +11 -17
  26. package/dist/adapters/memory/doctor.js +55 -68
  27. package/dist/adapters/memory/paths.d.ts +3 -10
  28. package/dist/adapters/memory/paths.js +16 -54
  29. package/dist/adapters/memory/vendored.d.ts +2 -7
  30. package/dist/adapters/memory/vendored.js +12 -57
  31. package/dist/adapters/memory/worker-control.d.ts +9 -0
  32. package/dist/adapters/memory/worker-control.js +30 -0
  33. package/dist/cli/cleanup.d.ts +1 -1
  34. package/dist/cli/cleanup.js +29 -14
  35. package/dist/cli/cost.js +2 -2
  36. package/dist/cli/doctor.d.ts +9 -9
  37. package/dist/cli/doctor.js +21 -16
  38. package/dist/cli/hard-cleanup.js +1 -1
  39. package/dist/cli/install-facts.d.ts +4 -3
  40. package/dist/cli/install-facts.js +3 -1
  41. package/dist/cli/profile.d.ts +1 -1
  42. package/dist/cli/profile.js +10 -10
  43. package/dist/index.js +7 -5
  44. package/dist/infra/fsutil.d.ts +4 -5
  45. package/dist/infra/fsutil.js +5 -6
  46. package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
  47. package/node_modules/@freeharness/core/dist/registry.js +3 -3
  48. package/node_modules/@freeharness/core/package.json +1 -1
  49. package/node_modules/@freeharness/sdd/package.json +1 -1
  50. package/package.json +3 -3
  51. package/provenance/canonical.PROVENANCE.yaml +2 -2
  52. package/provenance/memory.PROVENANCE.yaml +18 -4
  53. package/canonical/openspec/config.base.yaml +0 -89
  54. package/canonical/rules/safety.md +0 -41
@@ -51,10 +51,10 @@ const DIGEST_BUDGET = 15;
51
51
  const CEILING_RE = /^[1-9]\d*$/;
52
52
  /** Budget ceiling (cache-read tokens) of a task of no security, complex or final class. */
53
53
  const DEFAULT_CEILING = 25000000;
54
- /** Class ceilings (cache-read tokens) set from the measured spend: security and complex tasks
55
- * cost several times an ordinary task, and one shared limit would stop them while leaving
56
- * ordinary tasks loose. */
57
- export const CLASS_CEILINGS = Object.freeze({ security: 60000000, complex: 60000000, final: 50000000, other: DEFAULT_CEILING });
54
+ /** Class ceilings (cache-read tokens): security and complex tasks share the ordinary limit, so
55
+ * extra spend on them needs the owner's run authorization; only the final review, which reads
56
+ * the whole branch, gets more. */
57
+ export const CLASS_CEILINGS = Object.freeze({ security: DEFAULT_CEILING, complex: DEFAULT_CEILING, final: 50000000, other: DEFAULT_CEILING });
58
58
  /** Fix rounds a task may open when the change's run authorization sets no `Rounds:`. */
59
59
  const DEFAULT_ROUNDS = 3;
60
60
  /** The run authorization's lines; anything else in its section is refused, never skipped. */
@@ -71,18 +71,12 @@ const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\]
71
71
  const TASK_ANNOTATION_RE = /^ {2,}(?:Verify|Interfaces|Boundary|Depends|Disposition):/;
72
72
  /** What `brief-init` leaves where only the lead's judgment can supply the value. */
73
73
  export const PLACEHOLDER_RE = /<fill:/;
74
- /** One short fragment (or a `pattern`, or a `phrases` list needing every member) per required
75
- * `## Run rules` line; a brief missing one reports its `key`. `phrase` is a case-insensitive
76
- * substring; `phrases` needs each entry as its own substring, any order; `pattern` tests the
77
- * section text as-is. A brief may restate a rule in its own words around the match.
78
- * `no-full-suite` needs one bullet with `never run` or `do not run` before `Suite:`, and verify:
79
- * a bullet that only conditions the run, or fragments borrowed from other bullets, would otherwise pass. */
80
74
  const SPACE_RE = /\s/;
81
75
  const LINE_END = /[\n\r\p{Zl}\p{Zp}]/u;
82
- /** The `no-full-suite` test as one pass over the text: a bullet opens at a `- ` with a space or the
83
- * text start before it and runs to the next ` - ` or line end. Nested lazy lookaheads took
84
- * quadratic time on one long bullet. A bullet opening right inside the one before it is skipped
85
- * when its text is a suffix of that one's, so the scan never rereads a character. */
76
+ /** The `no-full-suite` test as one pass over the text, linear in its length: a bullet opens at a
77
+ * `- ` with a space or the text start before it and runs to the next ` - ` or line end. A bullet
78
+ * opening right inside the one before it is skipped when its text is a suffix of that one's, so
79
+ * the scan never rereads a character. */
86
80
  function noFullSuiteBullet(text) {
87
81
  const n = text.length;
88
82
  const isSpace = (ch) => ch !== undefined && SPACE_RE.test(ch);
@@ -101,6 +95,12 @@ function noFullSuiteBullet(text) {
101
95
  }
102
96
  return false;
103
97
  }
98
+ /** One short fragment (or a `pattern`, or a `phrases` list needing every member) per required
99
+ * `## Run rules` line; a brief missing one reports its `key`. `phrase` is a case-insensitive
100
+ * substring; `phrases` needs each entry as its own substring, any order; `pattern` tests the
101
+ * section text as-is. A brief may restate a rule in its own words around the match.
102
+ * `no-full-suite` needs `never run` or `do not run` before `Suite:`, and verify, in one bullet
103
+ * bounded by the next ` - ` or line end, so borrowed fragments never pass. */
104
104
  export const REQUIRED_RUN_RULES = [
105
105
  { key: "build-before-tests", phrase: "build before" },
106
106
  { key: "comment-share", phrase: "comment share" },
@@ -136,8 +136,6 @@ export const PROBE_RE = /^(P[1-9]\d*): (.+?) -> (.+)$/;
136
136
  export const PROBE_BOUND = 12;
137
137
  /** An expected outcome that admits the behavior, so a fix that over-denies fails a probe. */
138
138
  const ALLOW_SIDE_RE = /\b(?:allow|allowed|accept|accepted|accepts|pass|passes|passed|ok|exit 0)\b/i;
139
- /** A probe that exercises the change over many inputs rather than one hand-picked case. */
140
- const CORPUS_RE = /\b(?:corpus|replay|fixture|fixtures|generator|generated)\b/i;
141
139
  /** Most non-blank lines a brief's `## Project rules` may hold: workers run without CLAUDE.md, so
142
140
  * the section carries only the rules that bear on the write scope, never the whole file. */
143
141
  export const PROJECT_RULES_BOUND = 20;
@@ -353,7 +351,7 @@ function parseProbes(lines) {
353
351
  }
354
352
 
355
353
  /** Probe problems of one brief: the section's own, then, for a security or complex class, a
356
- * missing allow-side or corpus probe; those two are judged only once the section exists. */
354
+ * missing allow-side probe, judged only once the section exists. */
357
355
  function probeRuleErrors(name, b) {
358
356
  const errors = (b.probeErrors ?? []).map((e) => `${name}: ${e}`);
359
357
  const probes = b.probes ?? [];
@@ -362,9 +360,6 @@ function probeRuleErrors(name, b) {
362
360
  if (!probes.some((p) => ALLOW_SIDE_RE.test(p.expected))) {
363
361
  errors.push(`${name}: Class ${cls} needs an allow-side probe (expected outcome an allow or a pass)`);
364
362
  }
365
- if (!probes.some((p) => CORPUS_RE.test(`${p.id}: ${p.check} -> ${p.expected}`))) {
366
- errors.push(`${name}: Class ${cls} needs a corpus probe (a corpus, fixture replay or generator command)`);
367
- }
368
363
  return errors;
369
364
  }
370
365
 
@@ -862,9 +857,12 @@ function pathEscapesOrSymlinks(file, cwd) {
862
857
  return false;
863
858
  }
864
859
 
865
- /** True when the first 8 KB of `buf` holds a NUL byte (binary content). */
860
+ /** Bytes from the start of a file searched for a NUL byte, the test that marks it binary. */
861
+ const BINARY_SNIFF_BYTES = 8 * 1024;
862
+
863
+ /** True when the first 8 KiB of `buf` holds a NUL byte (binary content). */
866
864
  function looksBinary(buf) {
867
- const len = Math.min(buf.length, 8192);
865
+ const len = Math.min(buf.length, BINARY_SNIFF_BYTES);
868
866
  for (let i = 0; i < len; i++) if (buf[i] === 0x00) return true;
869
867
  return false;
870
868
  }
@@ -1087,14 +1085,16 @@ function isSpace(c) {
1087
1085
  }
1088
1086
 
1089
1087
  /** End index of the quoted literal opening at `i` (`q` long, quote char `ch`), past `n` when
1090
- * unterminated; a backslash always skips the next character. */
1091
- function literalEnd(text, i, ch, q) {
1088
+ * unterminated; a backslash always skips the next character. With `eol`, an unescaped line
1089
+ * terminator ends it, as in JavaScript. */
1090
+ function literalEnd(text, i, ch, q, eol = false) {
1092
1091
  const n = text.length;
1093
1092
  let k = i + q;
1094
1093
  while (k < n) {
1095
1094
  const c = text[k];
1096
- if (c === "\\") k += 2;
1095
+ if (c === "\\") k += text[k + 1] === "\r" && text[k + 2] === "\n" ? 3 : 2;
1097
1096
  else if (c === ch && (q === 1 || (text[k + 1] === ch && text[k + 2] === ch))) return k + q;
1097
+ else if (eol && (c === "\n" || c === "\r")) return k;
1098
1098
  else k++;
1099
1099
  }
1100
1100
  return n;
@@ -1145,7 +1145,7 @@ function jsCommentChars(text) {
1145
1145
  i = k + 1;
1146
1146
  regexOk = false;
1147
1147
  } else if (ch === "'" || ch === '"') {
1148
- i = literalEnd(text, i, ch, 1);
1148
+ i = literalEnd(text, i, ch, 1, true);
1149
1149
  regexOk = false;
1150
1150
  } else if (ch === "`" || (ch === "}" && holes.length > 0 && holes[holes.length - 1] === 0)) {
1151
1151
  if (ch === "}") holes.pop();
@@ -1675,7 +1675,7 @@ export function changedTermErrors(brief, cwd = process.cwd(), cache = new Map())
1675
1675
  const buf = Buffer.alloc(st.size);
1676
1676
  let n = 0;
1677
1677
  for (let r = 1; n < st.size && r > 0; n += r) r = fs.readSync(fd, buf, n, st.size - n, n);
1678
- if (!buf.subarray(0, Math.min(n, 8192)).includes(0)) text = buf.subarray(0, n).toString("utf8");
1678
+ if (!looksBinary(buf.subarray(0, n))) text = buf.subarray(0, n).toString("utf8");
1679
1679
  }
1680
1680
  } catch (err) {
1681
1681
  // A deleted file or a symlink holds nothing to search; any other failure is said.
@@ -1724,10 +1724,11 @@ export function changedTermErrors(brief, cwd = process.cwd(), cache = new Map())
1724
1724
  return errors;
1725
1725
  }
1726
1726
 
1727
- /** Problems of a batch: each brief's header, digest and tree, plus scope overlap. Mode `set`
1728
- * (briefs named one by one) adds the dispatch-set checks and the band `cap`; mode `dir` spares
1729
- * landed briefs their removed trees. Any other mode throws, so no missing cap skips the band. A
1730
- * malformed run authorization is one error per run. */
1727
+ /** Problems of a batch: each brief's header, digest and tree (tree-path, setup-missing), plus
1728
+ * scope overlap. `{ kind: "set", cap }` (named briefs) adds tree-required, resource-overlap,
1729
+ * manifest-parallel and `band` past `cap`; `{ kind: "dir" }` (landed briefs) skips those and
1730
+ * tree-absent; `single` skips the set checks. Another mode or a missing cap throws. A malformed
1731
+ * run authorization is one error per run. */
1731
1732
  export function validateBriefs(briefs, mode = { kind: "single" }) {
1732
1733
  const kind = mode && typeof mode === "object" ? mode.kind : undefined;
1733
1734
  if (!["single", "set", "dir"].includes(kind)) throw new TypeError(`validateBriefs mode must be { kind: 'single' | 'set' | 'dir' }; got ${JSON.stringify(mode)}`);
@@ -2033,58 +2034,28 @@ function collectBriefs(args) {
2033
2034
  /** Most bytes read of a transcript or a ledger: a worker transcript stays far below it, so a
2034
2035
  * larger file is runaway or hostile and its tail is dropped rather than buffered. */
2035
2036
  const SCAN_BOUND = 16 * 1024 * 1024;
2036
- /** Most lines one scan kind prints; one `bash-write-capped` note counts the rest. */
2037
+ /** Most lines one scan kind prints; one `scan-capped` note counts the rest. */
2037
2038
  const SCAN_CAP = 50;
2038
2039
  /** Most characters of a command or path one scan line quotes. */
2039
2040
  const SCAN_DETAIL = 200;
2040
2041
  /** Longest path the scan resolves: past PATH_MAX no write can succeed. */
2041
2042
  const SCAN_PATH_MAX = 4096;
2042
- /** Filesystem lookups one scan may spend before it compares paths by text alone, so a run of
2043
- * distinct targets cannot turn the scan into a syscall storm. */
2044
- const SCAN_FS_BUDGET = 10000;
2045
- /** Characters of relative path one scan may resolve against a directory, so a run of distinct
2046
- * words under a directory near PATH_MAX cannot cost bytes times PATH_MAX. */
2047
- const SCAN_PATH_BUDGET = 16 * 1024 * 1024;
2048
- /** Reserved words and `!` a command may follow; each counts only unquoted. */
2049
- const RESERVED_PREFIXES = new Set(["!", "{", "}", "if", "then", "else", "elif", "do", "while", "until"]);
2050
- /** Wrappers that run the command after their own options, keyed by basename. `short` letters and
2051
- * `long` names take a value, as the next word unless joined; any other option is a flag, so an
2052
- * unknown one never eats the command word. `tail` counts the operands read before the command,
2053
- * as the duration of `timeout`. The BSD letters (`env -LPU`, `xargs -JRS`) are absent from GNU,
2054
- * so consuming their value there only reads a word GNU would refuse. `chdir` names the letter
2055
- * and the shortest unambiguous `--chdir` prefix of the option that runs the command elsewhere. */
2056
- const COMMAND_PREFIXES = new Map([
2057
- ["time", { short: "", long: [] }],
2058
- ["nohup", { short: "", long: [] }],
2059
- ["exec", { short: "a", long: [] }],
2060
- ["command", { short: "", long: [] }],
2061
- ["env", { short: "uCSLPU", long: ["--unset", "--chdir", "--split-string"], chdir: ["C", 3] }],
2062
- ["sudo", { short: "ughpCDrtUacRT", long: ["--user", "--group", "--host", "--prompt", "--close-from", "--chdir", "--role", "--type", "--other-user", "--chroot", "--command-timeout", "--login-class", "--auth-type"], chdir: ["D", 5] }],
2063
- ["nice", { short: "n", long: ["--adjustment"] }],
2064
- ["timeout", { short: "sk", long: ["--signal", "--kill-after"], tail: 1 }],
2065
- ["xargs", { short: "adEILnPsJRS", long: ["--arg-file", "--delimiter", "--max-args", "--max-procs", "--max-chars"] }],
2066
- ["stdbuf", { short: "ioe", long: ["--input", "--output", "--error"] }],
2067
- ]);
2068
- const WRITE_REDIRECTS = new Set([">", ">>", ">&", "<>"]);
2069
- const SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
2070
- const WRITE_CALLS = ["writeFile", "appendFile", "createWriteStream", "fs.write", "write_text", "write_bytes", "print >", "File.write", "IO.write"];
2071
2043
  /** Git subcommands that write the index or HEAD whatever their arguments; matched by whole word,
2072
2044
  * so `merge-base` and `cherry` stay reads. */
2073
2045
  const INDEX_SUBCOMMANDS = new Set(["add", "rm", "mv", "commit", "reset", "switch", "merge", "rebase", "cherry-pick", "pull", "update-index"]);
2074
- /** The operand an `xargs` writer builds from stdin: an expanded word, so it is only noted. */
2075
- const XARGS_OPERAND = Object.freeze({ text: "xargs", expand: true, quoted: false });
2076
- /** How many `find -exec` levels one command is unwrapped, so nested finds stay linear. */
2077
- const FIND_DEPTH = 2;
2078
- /** Commands that write a copy, a move, an install or a link of a file, with the `g` names
2079
- * Homebrew gives GNU coreutils. */
2080
- const COPY_TOOLS = new Set(["cp", "mv", "install", "ln", "gcp", "gmv", "ginstall", "gln"]);
2081
- /** Long options of those commands whose value may be the next word, each with the shortest
2082
- * prefix GNU getopt accepts for it unambiguously. */
2083
- const COPY_LONG_VALUED = [["--target-directory", 3], ["--suffix", 4]];
2084
- const CP_LONG_VALUED = [["--sparse", 4], ["--no-preserve", 6]];
2085
- const INSTALL_LONG_VALUED = [["--mode", 3], ["--owner", 3], ["--group", 3], ["--strip-program", 8]];
2086
2046
  /** Git global options whose value is the next word. */
2087
2047
  const GIT_VALUED = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"]);
2048
+ /** Commands whose operands a Bash call writes or removes; `sed` counts only with `-i`. */
2049
+ const WRITE_WORDS = new Set(["tee", "cp", "mv", "install", "rm", "touch", "ln"]);
2050
+ /** Of those, the commands that take a `-t` target directory; all but `mv` write only the target. */
2051
+ const COPY_WORDS = new Set(["cp", "mv", "install", "ln"]);
2052
+ /** Words a command may run behind; only the bare word is skipped, never its options. */
2053
+ const WRAPPER_WORDS = new Set(["sudo", "env", "command", "nice", "nohup", "time", "exec", "xargs"]);
2054
+ /** The separators a Bash command is split on. Quotes are not parsed, so a separator inside a
2055
+ * quoted string splits there too: the scan reports, the diff guards decide. */
2056
+ const SEPARATOR_RE = /;|&&|\|\||\|/;
2057
+ /** A redirect word: its fd or `&`, the operator, an fd-duplicating `&` and a joined target. */
2058
+ const REDIRECT_RE = /^(?:\d*|&)>>?\|?(&?)(.*)$/;
2088
2059
 
2089
2060
  /** Up to `bound` bytes of `file` (its tail when `fromEnd`) through one no-follow, non-blocking
2090
2061
  * fd, so a symlinked leaf or a FIFO is refused rather than followed or waited on; null when
@@ -2190,289 +2161,6 @@ export function transcriptFor(agentId, changeDir) {
2190
2161
  return { paths: [...paths], notes: [...notes] };
2191
2162
  }
2192
2163
 
2193
- /** The operator at `src[i]` (`&`, `>` or `<`) and its width; `&>` and `&>>` write like `>`. */
2194
- function redirectOperator(src, i) {
2195
- const [a, b, c] = [src[i], src[i + 1], src[i + 2]];
2196
- if (a === "&") return c === ">" ? [">>", 3] : [">", 2];
2197
- if (a === ">") return b === ">" ? [">>", 2] : b === "|" ? [">", 2] : b === "&" ? [">&", 2] : [">", 1];
2198
- if (b === "<") return c === "<" ? ["<<<", 3] : c === "-" ? ["<<-", 3] : ["<<", 2];
2199
- return b === ">" ? ["<>", 2] : b === "&" ? ["<&", 2] : ["<", 1];
2200
- }
2201
-
2202
- /** Appends the `$…` or backquote expansion at `src[i]` to `w` and returns the index past it. A
2203
- * substitution or `${…}` marks `w.nested`: its end is found by counting, not by parsing the shell
2204
- * inside, so a comment or a case pattern there can end it at the wrong `)`. */
2205
- function expansion(src, i, w) {
2206
- w.expand = true;
2207
- const n = src.length;
2208
- let j = i + 1;
2209
- if (src[i] === "`" || src[j] === "(" || src[j] === "{") w.nested = true;
2210
- if (src[i] === "`") {
2211
- while (j < n && src[j] !== "`") j += src[j] === "\\" ? 2 : 1;
2212
- j = Math.min(j + 1, n);
2213
- } else if (src[j] === "(" || src[j] === "{") {
2214
- const open = src[j];
2215
- const close = open === "(" ? ")" : "}";
2216
- let depth = 0;
2217
- while (j < n) {
2218
- const ch = src[j];
2219
- if (ch === "\\") j += 2;
2220
- else if (ch === "'" || ch === '"') {
2221
- let k = j + 1;
2222
- while (k < n && src[k] !== ch) k += src[k] === "\\" && ch === '"' ? 2 : 1;
2223
- j = k + 1;
2224
- } else {
2225
- j++;
2226
- if (ch === open) depth++;
2227
- else if (ch === close && --depth === 0) break;
2228
- }
2229
- }
2230
- j = Math.min(j, n);
2231
- }
2232
- w.text += src.slice(i, j);
2233
- return j;
2234
- }
2235
-
2236
- /** Appends the double-quoted run from `src[i]` (past the opening quote) to `w`; returns the
2237
- * index past the closing quote. */
2238
- function doubleQuoted(src, i, w) {
2239
- const n = src.length;
2240
- while (i < n) {
2241
- const ch = src[i];
2242
- if (ch === '"') return i + 1;
2243
- if (ch === "$" || ch === "`") {
2244
- i = expansion(src, i, w);
2245
- continue;
2246
- }
2247
- if (ch === "\\" && i + 1 < n) {
2248
- const nx = src[i + 1];
2249
- if (nx !== "\n") w.text += '"\\$`'.includes(nx) ? nx : ch + nx;
2250
- i += 2;
2251
- continue;
2252
- }
2253
- w.text += ch;
2254
- i++;
2255
- }
2256
- return n;
2257
- }
2258
-
2259
- /** Characters the one-letter ANSI-C escapes stand for. */
2260
- const ANSI_C_NAMED = new Map([
2261
- ["a", "\x07"], ["b", "\b"], ["e", "\x1b"], ["E", "\x1b"], ["f", "\f"], ["n", "\n"],
2262
- ["r", "\r"], ["t", "\t"], ["v", "\v"], ["\\", "\\"], ["'", "'"], ['"', '"'],
2263
- ]);
2264
- /** Digit count and radix of the numeric ANSI-C escapes, keyed by the letter after the backslash. */
2265
- const ANSI_C_NUMERIC = new Map([["x", [2, 16]], ["u", [4, 16]], ["U", [8, 16]]]);
2266
-
2267
- /** The length of the run of up to `max` digits of `radix` at `src[i]`. */
2268
- function digitRun(src, i, max, radix) {
2269
- let k = i;
2270
- while (k < src.length && k - i < max && !Number.isNaN(parseInt(src[k], radix))) k++;
2271
- return k - i;
2272
- }
2273
-
2274
- /** Appends the `$'...'` body from `src[i]` (past the opening quote), escapes decoded as bash
2275
- * does, to `w`; returns the index past the closing quote. Bash keeps a decoded string up to its
2276
- * first NUL, so the text after one is dropped: a path tail past it never runs. */
2277
- function ansiC(src, i, w) {
2278
- const n = src.length;
2279
- let out = "";
2280
- let cut = false;
2281
- while (i < n && src[i] !== "'") {
2282
- let ch = src[i++];
2283
- if (ch === "\\" && i < n) {
2284
- const e = src[i++];
2285
- const numeric = ANSI_C_NUMERIC.get(e) ?? (e >= "0" && e <= "7" ? [3, 8] : null);
2286
- if (ANSI_C_NAMED.has(e)) ch = ANSI_C_NAMED.get(e);
2287
- else if (numeric === null) ch = `\\${e}`;
2288
- else {
2289
- const from = e === "x" || e === "u" || e === "U" ? i : i - 1;
2290
- const len = digitRun(src, from, numeric[0], numeric[1]);
2291
- const v = parseInt(src.slice(from, from + len), numeric[1]);
2292
- if (len === 0 || v > 0x10ffff) ch = src.slice(i - 2, from + len);
2293
- else ch = e === "u" || e === "U" ? String.fromCodePoint(v) : String.fromCharCode(v & 0xff);
2294
- i = from + len;
2295
- }
2296
- }
2297
- if (ch === "\0") cut = true;
2298
- else if (!cut) out += ch;
2299
- }
2300
- w.text += out;
2301
- return Math.min(i + 1, n);
2302
- }
2303
-
2304
- /** Attaches each pending heredoc's body, read line by line from `pos`, to its command; returns
2305
- * the index past the last delimiter line. */
2306
- function heredocBodies(src, pos, pending) {
2307
- const n = src.length;
2308
- for (const h of pending) {
2309
- const start = pos;
2310
- let end = n;
2311
- while (pos < n) {
2312
- const e = src.indexOf("\n", pos);
2313
- const lineEnd = e < 0 ? n : e;
2314
- let from = pos;
2315
- if (h.strip) while (from < lineEnd && src[from] === "\t") from++;
2316
- const next = e < 0 ? n : e + 1;
2317
- if (lineEnd - from === h.delim.length && src.startsWith(h.delim, from)) {
2318
- end = pos;
2319
- pos = next;
2320
- break;
2321
- }
2322
- pos = next;
2323
- }
2324
- h.cmd.bodies.push(src.slice(start, end));
2325
- }
2326
- pending.length = 0;
2327
- return pos;
2328
- }
2329
-
2330
- /** The entries `shellCommands` puts between commands where a subshell opens and closes; a close
2331
- * read after a construct the tokenizer does not parse exactly is `SUBSHELL_CLOSE_UNSURE`. */
2332
- const SUBSHELL_OPEN = Object.freeze({ scope: "open" });
2333
- const SUBSHELL_CLOSE = Object.freeze({ scope: "close" });
2334
- const SUBSHELL_CLOSE_UNSURE = Object.freeze({ scope: "close" });
2335
- /** The frame an open `case` holds on the scope stack: a `)` under it ends a pattern. */
2336
- const CASE_FRAME = Object.freeze({ case: true });
2337
- /** Reserved words after which a command word is still in keyword position. */
2338
- const LEAD_WORDS = new Set(["!", "{", "if", "then", "else", "elif", "do", "while", "until"]);
2339
-
2340
- /** The keyword state after word `t` of a command whose state was `lead`: `time` keeps it through
2341
- * `-p` and `--`, and `coproc` through one NAME, so a `case` after them is still the keyword. */
2342
- function nextLead(lead, t) {
2343
- if (t === "time" || t === "coproc") return t;
2344
- if (lead === "time" && (t === "-p" || t === "--")) return "time";
2345
- return LEAD_WORDS.has(t) || (lead === "coproc" && t !== "case" && /^[A-Za-z_]\w*$/.test(t));
2346
- }
2347
-
2348
- /** Splits shell source into simple commands in one left-to-right pass that consumes every
2349
- * quote, escape, expansion and heredoc body once and never backtracks, so hostile input costs
2350
- * time linear in its length. A word records whether an expansion touched it. A matched `( ... )`
2351
- * is bracketed by `SUBSHELL_OPEN` and `SUBSHELL_CLOSE`; a `((` reads as two of them until its
2352
- * inner `)` meets a second `)`, which makes it arithmetic and drops what was read inside, as
2353
- * bash decides. Once a substitution or a `case` word outside keyword position is read, a `)` may
2354
- * be misread, so every later close is `SUBSHELL_CLOSE_UNSURE`. */
2355
- function shellCommands(src) {
2356
- const n = src.length;
2357
- const commands = [];
2358
- const pending = [];
2359
- const frames = [];
2360
- let unsure = false;
2361
- let cmd = null;
2362
- let word = null;
2363
- let op = null;
2364
- const current = (i) => {
2365
- cmd ??= { start: i, end: i, words: [], redirects: [], bodies: [], lead: true };
2366
- return cmd;
2367
- };
2368
- const endWord = (i) => {
2369
- if (word === null) return;
2370
- const c = current(i);
2371
- c.end = i;
2372
- if (word.nested) unsure = true;
2373
- if (op === null) {
2374
- c.words.push(word);
2375
- const t = word.quoted || word.expand ? "" : word.text;
2376
- if (c.lead) {
2377
- if (t === "case") frames.push(CASE_FRAME);
2378
- else if (t === "esac" && frames.at(-1) === CASE_FRAME) frames.pop();
2379
- c.lead = nextLead(c.lead, t);
2380
- } else if (t === "case") unsure = true;
2381
- } else if (op === "<<" || op === "<<-") pending.push({ cmd: c, delim: word.text, strip: op === "<<-" });
2382
- else c.redirects.push({ op, target: word });
2383
- word = null;
2384
- op = null;
2385
- };
2386
- const endCommand = (i) => {
2387
- endWord(i);
2388
- op = null;
2389
- if (cmd !== null) commands.push(cmd);
2390
- cmd = null;
2391
- };
2392
- let i = 0;
2393
- while (i < n) {
2394
- const ch = src[i];
2395
- if (ch === "\n") {
2396
- endCommand(i);
2397
- i = heredocBodies(src, i + 1, pending);
2398
- } else if (ch === " " || ch === "\t" || ch === "\r") {
2399
- endWord(i);
2400
- i++;
2401
- } else if (ch === "(") {
2402
- endCommand(i);
2403
- const arith = src[i + 1] === "(" ? commands.length : undefined;
2404
- commands.push(SUBSHELL_OPEN);
2405
- frames.push({});
2406
- if (arith !== undefined) {
2407
- commands.push(SUBSHELL_OPEN);
2408
- frames.push({ arith });
2409
- }
2410
- i += arith === undefined ? 1 : 2;
2411
- } else if (ch === ")") {
2412
- endCommand(i);
2413
- const f = frames.at(-1);
2414
- i++;
2415
- if (f !== undefined && f !== CASE_FRAME) {
2416
- frames.pop();
2417
- if (f.arith !== undefined && src[i] === ")") {
2418
- frames.pop();
2419
- commands.length = f.arith;
2420
- i++;
2421
- } else commands.push(unsure ? SUBSHELL_CLOSE_UNSURE : SUBSHELL_CLOSE);
2422
- }
2423
- } else if (ch === ";") {
2424
- endCommand(i);
2425
- i++;
2426
- } else if (ch === "|" || (ch === "&" && src[i + 1] !== ">")) {
2427
- endCommand(i);
2428
- i += src[i + 1] === "|" || src[i + 1] === "&" ? 2 : 1;
2429
- } else if (ch === "&" || ch === ">" || ch === "<") {
2430
- const fd = ch !== "&" && word !== null && !word.quoted && !word.expand && /^\d+$/.test(word.text);
2431
- if (fd) word = null;
2432
- else endWord(i);
2433
- // A redirect ends keyword position: `>f case` runs a command named case.
2434
- current(i).lead = false;
2435
- const [token, width] = redirectOperator(src, i);
2436
- op = token;
2437
- i += width;
2438
- } else if (ch === "#" && word === null) {
2439
- const e = src.indexOf("\n", i);
2440
- i = e < 0 ? n : e;
2441
- } else if (ch === "\\" && src[i + 1] === "\n") {
2442
- i += 2;
2443
- } else {
2444
- current(i);
2445
- word ??= { text: "", expand: false, quoted: false };
2446
- const w = word;
2447
- if (ch === "\\") {
2448
- w.quoted = true;
2449
- w.text += src.slice(i + 1, i + 2);
2450
- i += 2;
2451
- } else if (ch === "'") {
2452
- w.quoted = true;
2453
- const e = src.indexOf("'", i + 1);
2454
- const end = e < 0 ? n : e;
2455
- w.text += src.slice(i + 1, end);
2456
- i = end + 1;
2457
- } else if (ch === '"') {
2458
- w.quoted = true;
2459
- i = doubleQuoted(src, i + 1, w);
2460
- } else if (ch === "$" && src[i + 1] === "'") {
2461
- w.quoted = true;
2462
- i = ansiC(src, i + 2, w);
2463
- } else if (ch === "$" || ch === "`") {
2464
- i = expansion(src, i, w);
2465
- } else {
2466
- if (ch === "~" && w.text === "" && !w.quoted) w.expand = true;
2467
- w.text += ch;
2468
- i++;
2469
- }
2470
- }
2471
- }
2472
- endCommand(n);
2473
- return commands;
2474
- }
2475
-
2476
2164
  /** Whitespace-collapsed `text`, JSON-quoted when it holds a control character so it cannot forge
2477
2165
  * an output line, bounded. */
2478
2166
  function scanDetail(text) {
@@ -2484,28 +2172,6 @@ function shownPath(p) {
2484
2172
  return quotedText(p).slice(0, SCAN_DETAIL);
2485
2173
  }
2486
2174
 
2487
- /** Spends `cost` of the scan's `kind` budget (`fs` lookups or path `chars`); false once that
2488
- * budget is spent, the first refusal adding one `bash-write-budget` note, since past it the scan
2489
- * decides by text alone or not at all. */
2490
- function spend(ctx, kind, cost) {
2491
- if (ctx.budget[kind] > 0) {
2492
- ctx.budget[kind] -= cost;
2493
- return true;
2494
- }
2495
- if (!ctx.notes.includes("bash-write-budget")) ctx.notes.push("bash-write-budget");
2496
- return false;
2497
- }
2498
-
2499
- /** The value `make()` gives for (`a`, `b`), computed once per pair. Nested maps keep the key the
2500
- * string itself, whose hash the engine caches, so a lookup costs no pass over a 4 KB path. */
2501
- function memo(map, a, b, make) {
2502
- let inner = map.get(a);
2503
- if (inner === undefined) map.set(a, (inner = new Map()));
2504
- let v = inner.get(b);
2505
- if (v === undefined) inner.set(b, (v = make()));
2506
- return v;
2507
- }
2508
-
2509
2175
  /** Records one scan line once; past the per-kind cap it only counts toward the capped note. */
2510
2176
  function emit(ctx, kind, line, note = false) {
2511
2177
  if (ctx.seen.has(line)) return;
@@ -2519,72 +2185,6 @@ function emit(ctx, kind, line, note = false) {
2519
2185
  (note ? ctx.notes : ctx.failures).push(line);
2520
2186
  }
2521
2187
 
2522
- /** Real path of `dir`, or of its longest existing ancestor joined with the rest. A loop with one
2523
- * lookup per ancestor, because a transcript's directory may hold thousands of components. */
2524
- function realDir(dir, ctx) {
2525
- const hit = ctx.dirs.get(dir);
2526
- if (hit !== undefined) return hit;
2527
- const abs = path.resolve(dir);
2528
- const rootLen = path.parse(abs).root.length;
2529
- let cut = abs.length;
2530
- let real;
2531
- for (;;) {
2532
- const head = cut < rootLen ? abs.slice(0, rootLen) : abs.slice(0, cut);
2533
- real = ctx.dirs.get(head);
2534
- if (real !== undefined) break;
2535
- if (!spend(ctx, "fs", 1)) {
2536
- real = head;
2537
- break;
2538
- }
2539
- try {
2540
- real = fs.realpathSync(head);
2541
- break;
2542
- } catch {
2543
- // Missing: try the parent.
2544
- }
2545
- if (cut < rootLen) {
2546
- real = head;
2547
- break;
2548
- }
2549
- cut = abs.lastIndexOf(path.sep, cut - 1);
2550
- }
2551
- const rest = cut >= abs.length ? "" : abs.slice(cut + 1);
2552
- const out = rest === "" ? real : `${real.endsWith(path.sep) ? real : real + path.sep}${rest}`;
2553
- ctx.dirs.set(dir, out);
2554
- return out;
2555
- }
2556
-
2557
- /** `abs` relative to the working tree after symlinks resolve, or null outside it; once per path.
2558
- * A path inside only by folded case joins `ctx.folded`. */
2559
- function insideTree(abs, ctx) {
2560
- const hit = ctx.inside.get(abs);
2561
- if (hit !== undefined) return hit;
2562
- let rel = null;
2563
- if (abs.length <= SCAN_PATH_MAX) {
2564
- const cut = abs.lastIndexOf(path.sep);
2565
- const dir = realDir(cut <= 0 ? path.sep : abs.slice(0, cut), ctx);
2566
- let real = abs.length === 1 ? abs : `${dir.endsWith(path.sep) ? dir : dir + path.sep}${abs.slice(cut + 1)}`;
2567
- if (spend(ctx, "fs", 1)) {
2568
- try {
2569
- const st = fs.lstatSync(real, { throwIfNoEntry: false });
2570
- if (st && st.isSymbolicLink()) real = fs.realpathSync(real);
2571
- } catch {
2572
- // A dangling or unreachable leaf is written where its text says.
2573
- }
2574
- }
2575
- rel = treeRelative(real, ctx.tree);
2576
- if (rel !== null && FOLD_CASE && treeRelative(real, ctx.tree, false) === null) ctx.folded.add(abs);
2577
- }
2578
- ctx.inside.set(abs, rel);
2579
- return rel;
2580
- }
2581
-
2582
- /** `insideTree`, or with `exact` null for a path inside only by folded case. */
2583
- function insideExactly(abs, ctx, exact) {
2584
- const rel = insideTree(abs, ctx);
2585
- return exact && ctx.folded.has(abs) ? null : rel;
2586
- }
2587
-
2588
2188
  /** Whether tree containment ignores case: the default macOS file system does, so a case-variant
2589
2189
  * path names the same file and must not read as outside the tree. */
2590
2190
  const FOLD_CASE = process.platform === "darwin";
@@ -2599,63 +2199,28 @@ export function treeRelative(real, tree, fold = FOLD_CASE) {
2599
2199
  return real.length > root.length && same(real.slice(0, root.length), root) ? real.slice(root.length) : null;
2600
2200
  }
2601
2201
 
2602
- /** The tree-relative posix path a write to `abs` lands on, or null when it is outside the tree,
2603
- * the tree itself, or under `.freeharness/`; once per path. With `ctx.main`, a write outside the
2604
- * tree that lands in the main root, outside its `.freeharness/`, fails `tree-leak`. */
2605
- function treeWrite(abs, ctx) {
2606
- const hit = ctx.trees.get(abs);
2607
- if (hit !== undefined) return hit;
2608
- const rel = insideTree(abs, ctx);
2609
- const out = writable(rel);
2610
- ctx.trees.set(abs, out);
2611
- // A case-sensitive volume holds another directory at a folded match, so its exemptions do not
2612
- // hold there and the leak an exact comparison finds still fails.
2613
- const leak = !ctx.main ? null : rel === null ? treeWrite(abs, ctx.main) : ctx.folded.has(abs) ? writable(insideExactly(abs, ctx.main, true)) : null;
2614
- if (leak !== null) emit(ctx, "tree-leak", `tree-leak ${shownPath(leak)}`);
2615
- return out;
2616
- }
2617
-
2618
- /** The change workspace `report` sits in, as a third tree sharing the scan's budgets and cache:
2619
- * `{ tree, report, shown }`, the real directory, the report's name in it and the directory as
2620
- * printed from the main root; null when the report is not under `.freeharness/sdd/<change>/`.
2621
- * The name is null unless the report is a regular file with one link, since a symlink or a hard
2622
- * link there would spare the trusted file it shares. */
2623
- function changeWorkspace(report, ctx) {
2624
- const abs = path.resolve(report);
2625
- const dir = realDir(path.dirname(abs), ctx);
2626
- const sdd = path.dirname(dir);
2627
- const fold = (s) => (FOLD_CASE ? s.toLowerCase() : s);
2628
- if (fold(path.basename(sdd)) !== "sdd" || fold(path.basename(path.dirname(sdd))) !== ".freeharness") return null;
2629
- const ws = { tree: dir, budget: ctx.budget, notes: ctx.notes, dirs: ctx.dirs, inside: new Map(), folded: new Set(), planted: new Map() };
2630
- let plain = false;
2202
+ /** The spellings of directory `dir` a transcript may print: as given, its real path and, on
2203
+ * macOS, the real path without `/private`, since `/tmp` and `/var` link into it. A fixed lookup
2204
+ * per root, so a target path is placed by its text alone. */
2205
+ function spellings(dir) {
2206
+ const out = new Set([path.resolve(dir)]);
2631
2207
  try {
2632
- const st = fs.lstatSync(path.join(dir, path.basename(abs)), { throwIfNoEntry: false });
2633
- plain = st !== undefined && st.isFile() && st.nlink === 1;
2208
+ const real = fs.realpathSync(dir);
2209
+ out.add(real);
2210
+ if (real.startsWith("/private/")) out.add(real.slice("/private".length));
2634
2211
  } catch {
2635
- // An unreadable leaf spares nothing.
2212
+ // A missing root is placed as written.
2636
2213
  }
2637
- ws.report = plain ? path.basename(abs) : null;
2638
- ws.shown = path.relative(path.dirname(path.dirname(sdd)), dir).split(path.sep).join("/");
2639
- return ws;
2214
+ return [...out];
2640
2215
  }
2641
2216
 
2642
- /** Whether a write to `abs` lands in the change workspace, failing `tree-leak` in a tree run,
2643
- * else `kind`; once per path. The directory itself is a hit, as a copy into it or its removal
2644
- * plants or drops files. Only a `tool` write to the exact-case report is spared: a shell write
2645
- * or link there may rewrite the file another name shares. */
2646
- function plantedWrite(abs, ctx, kind, tool = false) {
2647
- const ws = ctx.ws;
2648
- if (!ws) return false;
2649
- let hit = ws.planted.get(abs);
2650
- if (hit === undefined) {
2651
- const rel = insideTree(abs, ws);
2652
- hit = rel === null ? null : { report: rel === ws.report && !ws.folded.has(abs), shown: shownPath(rel === "" ? ws.shown : `${ws.shown}/${rel.split(path.sep).join("/")}`) };
2653
- ws.planted.set(abs, hit);
2217
+ /** `abs` relative to the first of `roots` it lies under ("" for a root itself), or null. */
2218
+ function under(abs, roots) {
2219
+ for (const root of roots) {
2220
+ const rel = treeRelative(abs, root);
2221
+ if (rel !== null) return rel;
2654
2222
  }
2655
- if (hit === null || (tool && hit.report)) return false;
2656
- const shown = hit.shown;
2657
- if (kind !== null || ctx.main) emit(ctx, ctx.main ? "tree-leak" : kind, `${ctx.main ? "tree-leak" : kind} ${shown}`);
2658
- return true;
2223
+ return null;
2659
2224
  }
2660
2225
 
2661
2226
  /** `rel` as a posix path, or null for no path, the root itself or a path under `.freeharness/`. */
@@ -2664,382 +2229,173 @@ function writable(rel) {
2664
2229
  return p === null || p === ".freeharness" || p.startsWith(".freeharness/") ? null : p;
2665
2230
  }
2666
2231
 
2667
- /** `dir` resolved against `here`; null when unknown, past PATH_MAX or past the path budget. Once
2668
- * per pair, and a relative `dir` charges the characters it resolves, so the scan stays linear in
2669
- * the transcript however long `here` is. */
2670
- function resolveDir(here, dir, ctx) {
2671
- const absolute = path.isAbsolute(dir);
2672
- if (here === null && !absolute) return null;
2673
- return memo(ctx.resolved, absolute ? null : here, dir, () => {
2674
- if (!absolute && !spend(ctx, "chars", here.length + dir.length)) return null;
2675
- const abs = path.resolve(here ?? "/", dir);
2676
- return abs.length > SCAN_PATH_MAX ? null : abs;
2677
- });
2678
- }
2679
-
2680
- /** Fails `bash-write` for a write target in the tree or the change workspace; an expanded one can
2681
- * only be noted. */
2682
- function writeTarget(word, here, ctx) {
2683
- const t = word.text;
2684
- if (word.expand || (here === null && !path.isAbsolute(t))) {
2685
- emit(ctx, "bash-write-unresolved", `bash-write-unresolved ${shownPath(t)}`, true);
2686
- return;
2232
+ /** The change workspace `report` sits in: `{ roots, report, shown }`, the directory's spellings,
2233
+ * the report's name in it and the directory as printed from the main root; null when the report
2234
+ * is not under `.freeharness/sdd/<change>/`. The name is null unless the report is a regular file
2235
+ * with one link, since a symlink or a hard link there would spare the trusted file it shares. */
2236
+ function changeWorkspace(report) {
2237
+ const abs = path.resolve(report);
2238
+ let dir = path.dirname(abs);
2239
+ const roots = spellings(dir);
2240
+ try {
2241
+ dir = fs.realpathSync(dir);
2242
+ } catch {
2243
+ // A missing workspace is placed as written.
2687
2244
  }
2688
- if (t === "" || t === "-" || t.startsWith("/dev/") || t.length > SCAN_PATH_MAX) return;
2689
- const line = memo(ctx.targets, here, t, () => {
2690
- const abs = resolveDir(here, t, ctx);
2691
- const p = abs === null ? null : treeWrite(abs, ctx);
2692
- if (p === null && abs !== null) plantedWrite(abs, ctx, "bash-write");
2693
- return p === null ? null : `bash-write ${shownPath(p)}`;
2694
- });
2695
- if (line !== null) emit(ctx, "bash-write", line);
2696
- }
2697
-
2698
- /** Commands that delete their operands. A worker deletes its own files with plain `rm`, so only
2699
- * a removal in the change workspace fails. */
2700
- const REMOVERS = new Set(["rm", "rmdir", "unlink", "grm", "grmdir", "gunlink"]);
2701
-
2702
- /** The operands of a removal: none of its options takes a value word, and from the first operand
2703
- * on every word is one, as BSD getopt stops there. */
2704
- function removeTargets(args) {
2705
- let k = 1;
2706
- while (k < args.length && args[k].text[0] === "-" && args[k].text !== "-") {
2707
- if (args[k++].text === "--") break;
2245
+ const sdd = path.dirname(dir);
2246
+ const fold = (s) => (FOLD_CASE ? s.toLowerCase() : s);
2247
+ if (fold(path.basename(sdd)) !== "sdd" || fold(path.basename(path.dirname(sdd))) !== ".freeharness") return null;
2248
+ let plain = false;
2249
+ try {
2250
+ const st = fs.lstatSync(path.join(dir, path.basename(abs)), { throwIfNoEntry: false });
2251
+ plain = st !== undefined && st.isFile() && st.nlink === 1;
2252
+ } catch {
2253
+ // An unreadable leaf spares nothing.
2708
2254
  }
2709
- return args.slice(k);
2255
+ return { roots, report: plain ? path.basename(abs) : null, shown: path.relative(path.dirname(path.dirname(sdd)), dir).split(path.sep).join("/") };
2710
2256
  }
2711
2257
 
2712
- /** Fails a removal of the change workspace or a file in it other than the report; an expanded
2713
- * operand is left alone, as removals elsewhere are never judged. */
2714
- function removedPath(word, here, ctx) {
2715
- const t = word.text;
2716
- if (word.expand || t === "" || t.length > SCAN_PATH_MAX || (here === null && !path.isAbsolute(t))) return;
2717
- const abs = resolveDir(here, t, ctx);
2718
- if (abs !== null) plantedWrite(abs, ctx, "bash-write");
2258
+ /** `rel` inside the change workspace as printed from the main root. */
2259
+ function workspacePath(ws, rel) {
2260
+ return shownPath(rel === "" ? ws.shown : `${ws.shown}/${rel.split(path.sep).join("/")}`);
2719
2261
  }
2720
2262
 
2721
- /** The directory a `cd` word list leaves the shell in; null when it cannot be known. */
2722
- function changeDir(args, here, ctx) {
2723
- const w = args.slice(1).find((a) => !(a.text.startsWith("-") && a.text !== "-"));
2724
- if (!w) return os.homedir();
2725
- return w.expand || w.text === "-" ? null : resolveDir(here, w.text, ctx);
2263
+ /** `word` with one pair of surrounding quotes dropped. */
2264
+ function unquote(word) {
2265
+ return word.length >= 2 && (word[0] === "'" || word[0] === '"') && word[word.length - 1] === word[0] ? word.slice(1, -1) : word;
2726
2266
  }
2727
2267
 
2728
- function teeTargets(args) {
2729
- let opts = true;
2730
- return args.slice(1).filter((w) => {
2731
- if (opts && w.text === "--") return (opts = false);
2732
- return !(opts && w.text.startsWith("-"));
2733
- });
2268
+ /** `word` as an absolute path from `here`, or null when it is no literal path: an expansion, a
2269
+ * glob, an option, a device, a URL, or a relative path from an unknown directory. */
2270
+ function literalPath(word, here) {
2271
+ const p = unquote(word);
2272
+ if (p === "" || p === "-" || p.length > SCAN_PATH_MAX || p[0] === "-" || p[0] === "~" || /[$`*?()<>]/.test(p)) return null;
2273
+ if (p.startsWith("/dev/") || p.includes("://") || (here === null && !path.isAbsolute(p))) return null;
2274
+ const abs = path.resolve(here ?? path.sep, p);
2275
+ return abs.length > SCAN_PATH_MAX ? null : abs;
2734
2276
  }
2735
2277
 
2736
- /** Files a `sed` word list edits in place: none without `-i`; the operands after the script,
2737
- * which is the first operand unless `-e` or `-f` gave one. A bare `-i` takes BSD's `''` or
2738
- * `.ext` suffix word. */
2739
- function sedTargets(args) {
2740
- let inPlace = false;
2741
- let script = false;
2742
- const operands = [];
2743
- for (let k = 1; k < args.length; k++) {
2744
- const t = args[k].text;
2745
- if (t.startsWith("--")) {
2746
- if (t === "--in-place" || t.startsWith("--in-place=")) inPlace = true;
2747
- else if (t === "--expression" || t === "--file") {
2278
+ /** The words of one command segment that name a written path: redirect targets, the operands of
2279
+ * `tee`, `rm`, `touch`, `mv` and `sed -i` (past the script and the `-e` or `-f` values), and
2280
+ * the last operand or `-t` directory of `cp`, `install` and `ln`, whose sources are only named.
2281
+ * An fd duplication such as `2>&1` writes no file. */
2282
+ function writeWords(words) {
2283
+ const out = [];
2284
+ for (let k = 0; k < words.length; k++) {
2285
+ const m = REDIRECT_RE.exec(words[k]);
2286
+ if (!m || (m[1] === "&" && /^(?:\d+|-)?$/.test(m[2]))) continue;
2287
+ if (m[2] !== "") out.push(m[2]);
2288
+ else if (k + 1 < words.length) out.push(words[++k]);
2289
+ }
2290
+ // The command word: past assignments and bare wrapper words, so `git rm` is no removal.
2291
+ let k = 0;
2292
+ while (k < words.length && (/^[A-Za-z_]\w*=/.test(words[k]) || WRAPPER_WORDS.has(words[k]))) k++;
2293
+ if (k < words.length) {
2294
+ const name = path.posix.basename(words[k]);
2295
+ const sed = name === "sed" && words.slice(k + 1).some((w) => /^(?:-[A-Za-z]*i|--in-place)/.test(w));
2296
+ if (!WRITE_WORDS.has(name) && !sed) return out;
2297
+ let ops = [];
2298
+ let target = null;
2299
+ let script = false;
2300
+ for (let j = k + 1; j < words.length; j++) {
2301
+ const w = words[j];
2302
+ const r = REDIRECT_RE.exec(w);
2303
+ if (r || w[0] === "<") {
2304
+ if ((r ? r[2] : w.replace(/^\d*<+-?/, "")) === "") j++;
2305
+ } else if (w[0] !== "-") ops.push(w);
2306
+ else if (sed && w === "-i" && (words[j + 1] === "''" || words[j + 1] === '""')) j++;
2307
+ else if (sed && /^(?:-e|-f|--expression|--file)$/.test(w)) {
2748
2308
  script = true;
2749
- k++;
2750
- } else if (t.startsWith("--expression=") || t.startsWith("--file=")) script = true;
2751
- } else if (t.length > 1 && t[0] === "-") {
2752
- for (let p = 1; p < t.length; p++) {
2753
- const last = p === t.length - 1;
2754
- if (t[p] === "i") {
2755
- inPlace = true;
2756
- const nx = args[k + 1];
2757
- if (last && nx && (nx.text === "" || (nx.text.startsWith(".") && !/[/\s]/.test(nx.text)))) k++;
2758
- break;
2759
- }
2760
- if (t[p] === "e" || t[p] === "f" || t[p] === "l") {
2761
- if (t[p] !== "l") script = true;
2762
- if (last) k++;
2763
- break;
2764
- }
2765
- }
2766
- } else operands.push(args[k]);
2767
- }
2768
- if (!inPlace) return [];
2769
- return script ? operands : operands.slice(1);
2770
- }
2771
-
2772
- /** Files a `perl` word list edits in place: none without `-i`; with `-e` every operand, else
2773
- * the operands after the script file. */
2774
- function perlTargets(args) {
2775
- let inPlace = false;
2776
- let code = false;
2777
- const operands = [];
2778
- let opts = true;
2779
- for (let k = 1; k < args.length; k++) {
2780
- const t = args[k].text;
2781
- if (opts && t === "--") opts = false;
2782
- else if (opts && t.length > 1 && t[0] === "-") {
2783
- for (let p = 1; p < t.length; p++) {
2784
- if (t[p] === "i") {
2785
- inPlace = true;
2786
- break;
2787
- }
2788
- if (t[p] === "e" || t[p] === "E") {
2789
- code = true;
2790
- if (p === t.length - 1) k++;
2791
- break;
2792
- }
2793
- if ("MmIxlCdDF0".includes(t[p])) break;
2794
- }
2795
- } else operands.push(args[k]);
2796
- }
2797
- if (!inPlace) return [];
2798
- return code ? operands : operands.slice(1);
2799
- }
2800
-
2801
- /** Paths a `cp`, `mv`, `install` or `ln` word list writes: every `-t`/`--target-directory`
2802
- * value, else the last of two or more operands, or the basename of a lone `ln` operand in the
2803
- * current directory; `install -d` creates every operand and `mv` deletes each source, so each
2804
- * operand is one there. Options count anywhere before `--`, as GNU getopt permutes them, and
2805
- * one taking a value consumes it, so a suffix, a mode or a sparse mode after the operands is no
2806
- * false last operand. BSD getopt stops at the first operand instead, so once an option word
2807
- * follows it every later operand is a target too, as is the last word (BSD's destination) even
2808
- * when a GNU option took it as its value, and for `mv` the first operand and every word after it
2809
- * (BSD's moved sources); a value taken before the last word stays out for the others, which BSD
2810
- * only reads. The GNU result comes first and is never dropped. */
2811
- function copyTargets(name, args) {
2812
- const tool = name.length > 2 && name[0] === "g" ? name.slice(1) : name;
2813
- const short = tool === "install" ? "mogSt" : "St";
2814
- const extra = tool === "install" ? INSTALL_LONG_VALUED.concat([["--directory", 4]]) : tool === "cp" ? CP_LONG_VALUED : [];
2815
- const long = COPY_LONG_VALUED.concat(extra);
2816
- const dirs = [];
2817
- const operands = [];
2818
- // Words from the first operand on that BSD would read as operands.
2819
- let bsd = [];
2820
- let first = -1;
2821
- let optionAfter = false;
2822
- let mkdirs = false;
2823
- let opts = true;
2824
- for (let k = 1; k < args.length; k++) {
2825
- const w = args[k];
2826
- const t = w.text;
2827
- const seen = operands.length > 0;
2828
- if (!opts || t === "-" || t[0] !== "-") {
2829
- if (!seen) first = k;
2830
- operands.push(w);
2831
- if (seen || tool === "mv") bsd.push(w);
2832
- continue;
2833
- }
2834
- if (seen) {
2835
- optionAfter = true;
2836
- if (tool === "mv" || k === args.length - 1) bsd.push(w);
2837
- }
2838
- if (t === "--") opts = false;
2839
- else if (t[1] === "-") {
2840
- const eq = t.indexOf("=");
2841
- const opt = eq < 0 ? t : t.slice(0, eq);
2842
- const hit = long.find(([full, min]) => opt.length >= min && full.startsWith(opt));
2843
- if (!hit) continue;
2844
- if (hit[0] === "--directory") {
2845
- mkdirs = true;
2846
- continue;
2847
- }
2848
- const value = eq < 0 ? args[++k] : { ...w, text: t.slice(eq + 1) };
2849
- if (hit[0] === "--target-directory" && value) dirs.push(value);
2850
- } else {
2851
- for (let p = 1; p < t.length; p++) {
2852
- if (tool === "install" && t[p] === "d") mkdirs = true;
2853
- if (!short.includes(t[p])) continue;
2854
- const value = p === t.length - 1 ? args[++k] : { ...w, text: t.slice(p + 1) };
2855
- if (t[p] === "t" && value) dirs.push(value);
2856
- break;
2857
- }
2858
- }
2859
- }
2860
- let gnu;
2861
- if (mkdirs || (dirs.length > 0 && tool === "mv")) gnu = dirs.concat(operands);
2862
- else if (dirs.length > 0) gnu = dirs;
2863
- else if (operands.length >= 2) gnu = tool === "mv" ? operands : [operands[operands.length - 1]];
2864
- else if (tool === "ln" && operands.length === 1) gnu = [operands[0].expand ? operands[0] : { ...operands[0], text: path.posix.basename(operands[0].text) }];
2865
- else gnu = [];
2866
- if (!optionAfter) return gnu;
2867
- if (tool === "mv") bsd = args.slice(first);
2868
- else if (!bsd.includes(args[args.length - 1])) bsd.push(args[args.length - 1]);
2869
- const kept = new Set(gnu);
2870
- return gnu.concat(bsd.filter((w) => !kept.has(w)));
2871
- }
2872
-
2873
- /** Operands of a word list whose options may sit anywhere before `--`: a short option in
2874
- * `short` or a long one in `long` takes the next word as its value unless joined, any other
2875
- * option is a flag. */
2876
- function permutedOperands(args, short, long) {
2877
- const operands = [];
2878
- let opts = true;
2879
- for (let k = 1; k < args.length; k++) {
2880
- const t = args[k].text;
2881
- if (!opts || t === "-" || t[0] !== "-") operands.push(args[k]);
2882
- else if (t === "--") opts = false;
2883
- else if (t[1] === "-") {
2884
- if (!t.includes("=") && long.includes(t)) k++;
2885
- } else {
2886
- for (let p = 1; p < t.length; p++) {
2887
- if (!short.includes(t[p])) continue;
2888
- if (p === t.length - 1) k++;
2889
- break;
2890
- }
2891
- }
2892
- }
2893
- return operands;
2894
- }
2895
-
2896
- /** Files a `touch` word list creates or stamps: every operand; `-r`, `-d`, `-t` and BSD's `-A`
2897
- * take a value. */
2898
- function touchTargets(args) {
2899
- return permutedOperands(args, "rdtA", ["--reference", "--date", "--time"]);
2900
- }
2901
-
2902
- /** Files a `truncate` word list sizes: every operand; `-s` and `-r` take a value. */
2903
- function truncateTargets(args) {
2904
- return permutedOperands(args, "sr", ["--size", "--reference"]);
2905
- }
2906
-
2907
- /** The files a `dd` word list writes: the value of each `of=`. */
2908
- function ddTargets(args) {
2909
- return args.slice(1).filter((w) => w.text.startsWith("of=")).map((w) => ({ ...w, text: w.text.slice(3) }));
2910
- }
2911
-
2912
- /** Collectors of the paths each file-writing command writes, keyed by name, with the `g` names
2913
- * Homebrew gives GNU coreutils for the coreutils ones. */
2914
- const WRITERS = new Map([
2915
- ["tee", teeTargets],
2916
- ["sed", sedTargets],
2917
- ["perl", perlTargets],
2918
- ...["touch", "truncate", "dd"].flatMap((n) => {
2919
- const f = n === "touch" ? touchTargets : n === "truncate" ? truncateTargets : ddTargets;
2920
- return [[n, f], [`g${n}`, f]];
2921
- }),
2922
- ...[...COPY_TOOLS].map((n) => [n, (args) => copyTargets(n, args)]),
2923
- ...["link", "glink"].map((n) => [n, linkTargets]),
2924
- ]);
2925
-
2926
- /** The name `link FILE1 FILE2` creates: its second operand. */
2927
- function linkTargets(args) {
2928
- const ops = args.slice(1).filter((w) => w.text !== "--");
2929
- return ops.slice(1, 2);
2930
- }
2931
-
2932
- /** The index of the command word in `words` from `from` past every reserved word, assignment and
2933
- * wrapper with its options, whether `xargs` was one of the wrappers, and each wrapper's chdir
2934
- * directory word in order, every one read from the previous. Wrappers nest, as in `sudo env -i nice -n 5 cp`, and match quoted
2935
- * or by path, as the shell runs them either way. */
2936
- function unwrapCommand(words, from) {
2937
- let k = from;
2938
- let xargs = false;
2939
- const chdirs = [];
2940
- while (k < words.length) {
2941
- const w = words[k];
2942
- if (!w.quoted && RESERVED_PREFIXES.has(w.text)) k++;
2943
- else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text)) k++;
2944
- else {
2945
- const name = w.expand ? "" : path.posix.basename(w.text);
2946
- const spec = COMMAND_PREFIXES.get(name);
2947
- if (spec === undefined) break;
2948
- if (name === "xargs") xargs = true;
2949
- k = prefixOptions(words, k + 1, spec, chdirs);
2950
- }
2309
+ j++;
2310
+ } else if (sed && /^(?:-e|-f|--expression=|--file=)/.test(w)) script = true;
2311
+ else if (COPY_WORDS.has(name) && w === "-t") target = words[++j] ?? null;
2312
+ else if (COPY_WORDS.has(name) && w.startsWith("--target-directory=")) target = w.slice("--target-directory=".length);
2313
+ else if (name === "install" && /^-[mog]$/.test(w)) j++;
2314
+ }
2315
+ // Without -e or -f the first operand is the sed script, no path.
2316
+ if (sed && !script) ops.shift();
2317
+ // An mv removes its sources, so each is a written path; cp, install and ln only read theirs.
2318
+ if (COPY_WORDS.has(name) && target !== null) ops = name === "mv" ? [...ops, target] : [target];
2319
+ else if (COPY_WORDS.has(name) && name !== "mv") ops = ops.slice(-1);
2320
+ out.push(...ops);
2951
2321
  }
2952
- return { k, xargs, chdirs };
2322
+ return out;
2953
2323
  }
2954
2324
 
2955
- /** The index past the options of one wrapper starting at `k`, and past its `tail` operands; the
2956
- * value word of its last chdir option joins `chdirs`, since env and sudo keep only that one and
2957
- * read it from where they start. */
2958
- function prefixOptions(words, k, spec, chdirs) {
2959
- const [letter, min] = spec.chdir ?? ["", 0];
2960
- // A missing value leaves no command word, so nothing is judged from it.
2961
- const valueAt = (i) => words[i] ?? { text: "", expand: true, quoted: false };
2962
- let dir = null;
2963
- while (k < words.length) {
2964
- const w = words[k];
2965
- const t = w.text;
2966
- if (t === "--") {
2967
- k++;
2968
- break;
2969
- }
2970
- if (t[0] !== "-") break;
2971
- k++;
2972
- if (t.startsWith("--")) {
2973
- const eq = t.indexOf("=");
2974
- const opt = eq < 0 ? t : t.slice(0, eq);
2975
- if (min > 0 && opt.length >= min && "--chdir".startsWith(opt)) {
2976
- dir = eq < 0 ? valueAt(k++) : { ...w, text: t.slice(eq + 1) };
2977
- } else if (eq < 0 && spec.long.includes(t)) k++;
2978
- continue;
2979
- }
2980
- for (let p = 1; p < t.length; p++) {
2981
- if (!spec.short.includes(t[p])) continue;
2982
- const last = p === t.length - 1;
2983
- if (t[p] === letter) dir = last ? valueAt(k) : { ...w, text: t.slice(p + 1) };
2984
- if (last) k++;
2985
- break;
2986
- }
2325
+ /** Notes `bash-path` for a path a Bash command names: any path in the change workspace or, in a
2326
+ * tree run, in the main root outside the tree, its admin dir and its `.freeharness/`; and a `write` target in
2327
+ * the tree outside the write scope and `.freeharness/`, or outside the tree. A note, never a
2328
+ * failure: the diff guards and the lead's diff read decide. */
2329
+ function bashPath(abs, write, ctx) {
2330
+ const inWs = ctx.ws ? under(abs, ctx.ws.roots) : null;
2331
+ if (inWs !== null) return emit(ctx, "bash-path", `bash-path ${workspacePath(ctx.ws, inWs)}`, true);
2332
+ const rel = under(abs, ctx.tree);
2333
+ if (rel !== null) {
2334
+ const p = writable(rel);
2335
+ if (write && p !== null && !ctx.scope.some((s) => scopeMatches(s, p))) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
2336
+ return;
2987
2337
  }
2988
- if (dir !== null) chdirs.push(dir);
2989
- return Math.min(k + (spec.tail ?? 0), words.length);
2338
+ const inMain = ctx.main ? under(abs, ctx.main) : null;
2339
+ if (inMain !== null) {
2340
+ const p = writable(inMain);
2341
+ // The tree's admin dir belongs to the tree: like a tree path, only a write there is noted.
2342
+ const own = !write && adminDir(ctx) !== null && under(abs, ctx.admin) !== null;
2343
+ if (p !== null && !own) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
2344
+ } else if (write) emit(ctx, "bash-path", `bash-path ${shownPath(abs)}`, true);
2990
2345
  }
2991
2346
 
2992
- /** The command word lists `find` runs through `-exec`, `-execdir`, `-ok` and `-okdir`, each up to
2993
- * its `;` or to a `+` right after `{}`; a word holding `{}` becomes an expanded operand, since
2994
- * find fills in each found path. */
2995
- function execSegments(args) {
2996
- const out = [];
2997
- for (let k = 1; k < args.length; k++) {
2998
- if (!/^-(?:exec|execdir|ok|okdir)$/.test(args[k].text)) continue;
2999
- const seg = [];
3000
- for (k++; k < args.length; k++) {
3001
- const t = args[k].text;
3002
- if (t === ";" || (t === "+" && seg.length > 0 && seg[seg.length - 1].text === "{}")) break;
3003
- seg.push(t.includes("{}") ? { ...args[k], expand: true } : args[k]);
3004
- }
3005
- out.push(seg);
2347
+ /** The spellings of the tree's git admin dir, looked up once per scan; null when git names none. */
2348
+ function adminDir(ctx) {
2349
+ if (ctx.admin === undefined) {
2350
+ const info = treeInfo(ctx.mainDir, ctx.treeDir);
2351
+ ctx.admin = info.ok ? spellings(info.gitDir) : null;
3006
2352
  }
3007
- return out;
2353
+ return ctx.admin;
3008
2354
  }
3009
2355
 
3010
2356
  /** Fails `index-write` for a git command that writes the index or HEAD when its effective
3011
2357
  * directory lies in the working tree or its admin dir, and `tree-leak` when it lies elsewhere in
3012
2358
  * the main root; a temporary repository stays free. `--git-dir`, `--work-tree` and the `GIT_DIR`,
3013
- * `GIT_WORK_TREE` and `GIT_INDEX_FILE` assignments in `env` are judged too, since they move the
3014
- * index a command writes away from its directory. */
3015
- function gitIndexWrite(args, here, text, ctx, env = []) {
2359
+ * `GIT_WORK_TREE` and `GIT_INDEX_FILE` assignments before `git` are judged too, since they move the
2360
+ * index a command writes away from its directory. `git` counts only as the command word: every
2361
+ * word before it is an assignment, a wrapper word, an option after a wrapper or that option's
2362
+ * value, so `sudo -u me git` counts while `echo remember to git add` does not. */
2363
+ function gitIndexWrite(words, here, text, ctx) {
2364
+ let g = 0;
2365
+ let wrapped = false;
2366
+ while (g < words.length && words[g] !== "git" && !words[g].endsWith("/git")) {
2367
+ const w = words[g];
2368
+ const value = wrapped && g > 0 && words[g - 1][0] === "-" && w[0] !== "-";
2369
+ if (WRAPPER_WORDS.has(w)) wrapped = true;
2370
+ else if (!/^[A-Za-z_]\w*=/.test(w) && !(wrapped && w[0] === "-") && !value) return;
2371
+ g++;
2372
+ }
2373
+ if (g >= words.length) return;
3016
2374
  let dir = here;
3017
2375
  // Values git resolves only after every -C, as it does.
3018
2376
  const repo = [];
3019
- for (const w of env) {
3020
- const m = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE)=/.exec(w.text);
3021
- if (m) repo.push(w.expand ? null : w.text.slice(m[0].length));
2377
+ for (const w of words.slice(0, g)) {
2378
+ const m = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE)=/.exec(w);
2379
+ if (m) repo.push(w.slice(m[0].length));
3022
2380
  }
3023
- let k = 1;
3024
- while (k < args.length && args[k].text.startsWith("-")) {
3025
- const t = args[k].text;
2381
+ let k = g + 1;
2382
+ while (k < words.length && words[k].startsWith("-")) {
2383
+ const t = words[k];
3026
2384
  const eq = /^--(?:git-dir|work-tree)=/.exec(t);
3027
2385
  if (t === "-C") {
3028
- const d = args[k + 1];
3029
- dir = !d || d.expand ? null : resolveDir(dir, d.text, ctx);
2386
+ dir = words[k + 1] === undefined ? null : literalPath(words[k + 1], dir);
3030
2387
  k += 2;
3031
2388
  } else if (eq) {
3032
- repo.push(args[k].expand ? null : t.slice(eq[0].length));
2389
+ repo.push(t.slice(eq[0].length));
3033
2390
  k++;
3034
2391
  } else if (t === "--git-dir" || t === "--work-tree") {
3035
- const d = args[k + 1];
3036
- repo.push(!d || d.expand ? null : d.text);
2392
+ if (words[k + 1] !== undefined) repo.push(words[k + 1]);
3037
2393
  k += 2;
3038
2394
  } else k += GIT_VALUED.has(t) ? 2 : 1;
3039
2395
  }
3040
- if (k >= args.length) return;
3041
- const sub = args[k].text;
3042
- const rest = args.slice(k + 1).map((w) => w.text);
2396
+ if (k >= words.length) return;
2397
+ const sub = words[k];
2398
+ const rest = words.slice(k + 1);
3043
2399
  const writes =
3044
2400
  INDEX_SUBCOMMANDS.has(sub) ||
3045
2401
  (sub === "stash" && rest[0] !== "list" && rest[0] !== "show") ||
@@ -3049,182 +2405,73 @@ function gitIndexWrite(args, here, text, ctx, env = []) {
3049
2405
  if (!writes) return;
3050
2406
  const dirs = dir === null ? [] : [dir];
3051
2407
  for (const v of repo) {
3052
- const abs = v === null || v === "" || v.length > SCAN_PATH_MAX ? null : resolveDir(dir, v, ctx);
2408
+ const abs = literalPath(v, dir);
3053
2409
  if (abs !== null) dirs.push(abs);
3054
2410
  }
3055
- if (ctx.main && ctx.admin === undefined) {
3056
- const info = treeInfo(ctx.main.tree, ctx.tree);
3057
- ctx.admin = info.ok ? path.relative(ctx.main.tree, info.gitDir) : null;
3058
- }
2411
+ const admin = ctx.main ? adminDir(ctx) : null;
3059
2412
  // Only git reads the admin dir as the tree's index; a file write there stays a leak.
3060
- const own = (d, exact) => {
3061
- if (insideExactly(d, ctx, exact) !== null) return true;
3062
- const m = ctx.main && ctx.admin !== null ? insideExactly(d, ctx.main, exact) : null;
3063
- return m !== null && (m === ctx.admin || m.startsWith(ctx.admin + path.sep));
3064
- };
3065
- if (dirs.some((d) => own(d, false))) emit(ctx, "index-write", `index-write ${scanDetail(text)}`);
3066
- // A leak either comparison finds fails, so folded case never removes one.
3067
- const leaks = (d, exact) => !own(d, exact) && insideExactly(d, ctx.main, exact) !== null;
3068
- if (ctx.main && dirs.some((d) => leaks(d, true) || leaks(d, false))) emit(ctx, "tree-leak", `tree-leak ${scanDetail(text)}`);
3069
- }
3070
-
3071
- /** Code bodies an interpreter word list runs: `-c`, `-e`-style arguments, here-strings and
3072
- * heredocs. */
3073
- function interpreterBodies(flags, args, c) {
3074
- const bodies = [];
3075
- for (let k = 1; k < args.length - 1; k++) {
3076
- const t = args[k].text;
3077
- if (flags.includes(t) || (/^-[A-Za-z]+$/.test(t) && flags.includes(`-${t[t.length - 1]}`))) bodies.push(args[++k].text);
3078
- }
3079
- for (const r of c.redirects) if (r.op === "<<<") bodies.push(r.target.text);
3080
- return bodies.concat(c.bodies);
3081
- }
3082
-
3083
- /** Quoted string literals of one line each in `body`. */
3084
- function quotedLiterals(body) {
3085
- const out = [];
3086
- const n = body.length;
3087
- let i = 0;
3088
- while (i < n) {
3089
- const q = body[i];
3090
- if (q !== "'" && q !== '"' && q !== "`") {
3091
- i++;
2413
+ const own = (d) => under(d, ctx.tree) !== null || (admin !== null && under(d, admin) !== null);
2414
+ if (dirs.some(own)) emit(ctx, "index-write", `index-write ${scanDetail(text)}`);
2415
+ if (ctx.main && dirs.some((d) => !own(d) && under(d, ctx.main) !== null)) emit(ctx, "tree-leak", `tree-leak ${scanDetail(text)}`);
2416
+ }
2417
+
2418
+ /** Applies the literal checks to one Bash command run from `here`. The command splits on the
2419
+ * separators alone and each segment on whitespace; a leading `cd` or `pushd` moves `here` for
2420
+ * the segments after it, and an expanded directory leaves it unknown. */
2421
+ function scanBash(command, here, ctx) {
2422
+ let cwdNoted = false;
2423
+ for (const segment of command.split(SEPARATOR_RE)) {
2424
+ const words = segment.trim().split(/\s+/).filter((w) => w !== "");
2425
+ if (words.length === 0) continue;
2426
+ if (words[0] === "cd" || words[0] === "pushd") {
2427
+ const arg = words.slice(1).find((w) => !/^-[A-Za-z@]+$/.test(w));
2428
+ here = arg === undefined ? null : literalPath(arg, here);
3092
2429
  continue;
3093
2430
  }
3094
- let j = i + 1;
3095
- while (j < n && body[j] !== q && body[j] !== "\n") j += body[j] === "\\" ? 2 : 1;
3096
- if (j < n && body[j] === q) {
3097
- out.push(body.slice(i + 1, j));
3098
- i = j + 1;
3099
- } else i = j;
3100
- }
3101
- return out;
3102
- }
3103
-
3104
- function isWriteMode(m) {
3105
- return m.startsWith(">") || m.startsWith("+<") || (m.length <= 4 && /^[rwaxbtU+]+$/.test(m) && /[wax+]/.test(m));
3106
- }
3107
-
3108
- /** Whether an `open(` call in `body` passes a write mode within its first 200 characters. */
3109
- function opensForWrite(body) {
3110
- for (let at = body.indexOf("open("); at >= 0; at = body.indexOf("open(", at + 5)) {
3111
- const window = body.slice(at + 5, at + 205);
3112
- const cut = window.indexOf(")");
3113
- if (quotedLiterals(cut < 0 ? window : window.slice(0, cut)).some(isWriteMode)) return true;
3114
- }
3115
- return false;
3116
- }
3117
-
3118
- /** Whether an interpreter body calls a file-write function and names a literal path that lands
3119
- * in the working tree outside `.freeharness/`, or in the change workspace of a shared-tree run. */
3120
- function writesInTree(body, here, ctx) {
3121
- if (!WRITE_CALLS.some((s) => body.includes(s)) && !opensForWrite(body)) return false;
3122
- for (const lit of quotedLiterals(body)) {
3123
- let s = 0;
3124
- while (s < lit.length && "<>+ ".includes(lit[s])) s++;
3125
- const p = lit.slice(s);
3126
- if (p === "" || p.length > SCAN_PATH_MAX || p[0] === "-" || /\s/.test(p) || p.includes("://") || !/[./]/.test(p)) continue;
3127
- const abs = resolveDir(here, p, ctx);
3128
- if (abs !== null && treeWrite(abs, ctx) !== null) return true;
3129
- // A tree run fails the workspace path itself, so the interpreter line is the shared run's.
3130
- if (abs !== null && plantedWrite(abs, ctx, null) && !ctx.main) return true;
3131
- }
3132
- return false;
3133
- }
3134
-
3135
- /** Applies every check to the simple commands of one shell source run from `here`; a `cd`
3136
- * carries to the commands after it up to the end of its subshell, and a shell's own `-c` body
3137
- * is scanned two levels deep. `flat` is the directory with every `cd` carried past its subshell;
3138
- * after a close that may be misread, each target is judged from both, so a misread `)` can only
3139
- * add failures. */
3140
- function scanShell(src, here, ctx, depth) {
3141
- let noted = false;
3142
- const saved = [];
3143
- let flat = here;
3144
- let unsure = false;
3145
- for (const c of shellCommands(src)) {
3146
- if (c === SUBSHELL_OPEN) saved.push(here);
3147
- else if (c === SUBSHELL_CLOSE || c === SUBSHELL_CLOSE_UNSURE) {
3148
- unsure ||= c === SUBSHELL_CLOSE_UNSURE;
3149
- if (saved.length > 0) here = saved.pop();
3150
- }
3151
- if (c.scope !== undefined) continue;
3152
- const dirs = unsure && flat !== here ? [here, flat] : [here];
3153
- const { k, xargs, chdirs } = unwrapCommand(c.words, 0);
3154
- const args = c.words.slice(k);
3155
- const name = args.length > 0 ? path.posix.basename(args[0].text) : "";
3156
- const text = src.slice(c.start, c.end);
3157
- if (name !== "[[") {
3158
- for (const r of c.redirects) {
3159
- if (WRITE_REDIRECTS.has(r.op) && !(r.op === ">&" && /^(?:\d+|-)$/.test(r.target.text))) for (const d of dirs) writeTarget(r.target, d, ctx);
2431
+ if (ctx.main && !cwdNoted && here !== null && under(here, ctx.tree) === null) {
2432
+ cwdNoted = true;
2433
+ emit(ctx, "tree-cwd", `tree-cwd ${scanDetail(command)}`, true);
2434
+ }
2435
+ gitIndexWrite(words, here, segment, ctx);
2436
+ const written = writeWords(words);
2437
+ const named = words.filter((w) => !written.includes(w) && unquote(w).includes("/"));
2438
+ for (const [list, write] of [[written, true], [named, false]]) {
2439
+ for (const w of list) {
2440
+ const abs = literalPath(w, here);
2441
+ if (abs !== null) bashPath(abs, write, ctx);
3160
2442
  }
3161
2443
  }
3162
- // The note quotes the whole call, so it is built once per call, keeping the scan linear; a
3163
- // leading cd places it.
3164
- if (!noted && ctx.main && depth === 0 && name !== "" && name !== "cd" && name !== "pushd" && here !== null && insideTree(here, ctx) === null) {
3165
- noted = true;
3166
- emit(ctx, "tree-cwd", `tree-cwd ${scanDetail(src)}`, true);
3167
- }
3168
- if (name === "cd" || name === "pushd") {
3169
- const was = here;
3170
- here = changeDir(args, here, ctx);
3171
- flat = flat === was ? here : changeDir(args, flat, ctx);
3172
- }
3173
- if (name !== "cd" && name !== "pushd") for (const d of dirs) judgeWrapped(args, c.words.slice(0, k), xargs, chdirs, { c, here: d, ctx, text, depth, finds: 0 });
3174
- if (ctx.suite !== "" && text.replace(/\s+/g, " ").includes(ctx.suite)) emit(ctx, "suite-run", "suite-run");
3175
- }
3176
- }
3177
-
3178
- /** `judgeCommand` from the shell's directory `at.here` and, behind a wrapper chdir, also from an
3179
- * unknown one: the chdir value is never resolved, so an absolute target is judged as from the
3180
- * shell and a relative one is only noted. The redirects stay the shell's. */
3181
- function judgeWrapped(args, env, xargs, chdirs, at) {
3182
- judgeCommand(args, env, xargs, at);
3183
- if (chdirs.length > 0) judgeCommand(args, env, xargs, { ...at, here: null });
3184
- }
3185
-
3186
- /** Applies the write, index and interpreter checks to one command word list `args`, run behind
3187
- * the prefix words `env`, from `at.here`. Under `xargs` a writer also notes the operand stdin
3188
- * supplies, and each `find -exec` command is judged in turn, `FIND_DEPTH` levels deep. */
3189
- function judgeCommand(args, env, xargs, at) {
3190
- const { c, here, ctx, text, depth } = at;
3191
- const name = args.length > 0 ? path.posix.basename(args[0].text) : "";
3192
- const collect = WRITERS.get(name);
3193
- if (collect) {
3194
- for (const w of collect(args)) writeTarget(w, here, ctx);
3195
- if (xargs) writeTarget(XARGS_OPERAND, here, ctx);
3196
- } else if (REMOVERS.has(name)) {
3197
- if (ctx.ws) for (const w of removeTargets(args)) removedPath(w, here, ctx);
3198
- } else if (name === "git") gitIndexWrite(args, here, text, ctx, env);
3199
- else if (name === "find" && at.finds < FIND_DEPTH) {
3200
- // The heredocs and here-strings stay with the outer command, so a run of -exec shells does
3201
- // not rescan one body per segment.
3202
- const inner = { ...at, c: { redirects: [], bodies: [] }, finds: at.finds + 1 };
3203
- for (const seg of execSegments(args)) {
3204
- const u = unwrapCommand(seg, 0);
3205
- judgeWrapped(seg.slice(u.k), seg.slice(0, u.k), xargs || u.xargs, u.chdirs, inner);
3206
- }
3207
2444
  }
3208
- if (SHELLS.has(name)) {
3209
- if (depth < 2) for (const body of interpreterBodies(["-c"], args, c)) scanShell(body, here, ctx, depth + 1);
3210
- } else {
3211
- const flags = /^python[0-9.]*$/.test(name) ? ["-c"] : name === "node" || name === "nodejs" ? ["-e", "--eval", "-p", "--print"] : name === "perl" || name === "ruby" ? ["-e", "-E"] : null;
3212
- if (flags && interpreterBodies(flags, args, c).some((b) => writesInTree(b, here, ctx))) emit(ctx, "bash-write", `bash-write ${shownPath(name)}`);
3213
- }
3214
- }
3215
-
3216
- /** Scans every Bash call a worker transcript records (at most `SCAN_BOUND` bytes of it) for
3217
- * edits outside the Edit and Write tools, index writes and a run of the brief's `Suite:`
3218
- * command; the working tree is the git top-level of `cwd`, or `cwd` itself with a
3219
- * `transcript-tree-unresolved <git's first error line>` note when git names none. Malformed
3220
- * lines are skipped. Returns `{ failures, notes }`: the FAIL and NOTE line bodies in scan order;
3221
- * an unreadable transcript gives no failures and the one note `transcript-unavailable unreadable`.
3222
- * With `mainRoot`, `cwd` is a task's tree: a write under the main root outside the tree, from a
3223
- * Bash command or the Edit, MultiEdit, Write and NotebookEdit tools, fails `tree-leak`, as does an index
3224
- * write there, and a command run outside the tree is noted `tree-cwd`. With `report`, the path
3225
- * of the review package in `<root>/.freeharness/sdd/<change>/`, any write in that directory but a
3226
- * tool write to the report fails too: `tree-leak` with `mainRoot`, else `bash-write` from Bash and
3227
- * `workspace-write` from a tool, since the guard trusts the ledger and round copies there. */
2445
+ if (ctx.suite !== "" && command.replace(/\s+/g, " ").includes(ctx.suite)) emit(ctx, "suite-run", "suite-run");
2446
+ }
2447
+
2448
+ /** Judges one Edit, MultiEdit, Write or NotebookEdit path: in a tree run a path under the main
2449
+ * root outside the tree and its `.freeharness/` fails `tree-leak`; a path in the change workspace
2450
+ * other than a spared report fails `tree-leak` in a tree run, else `workspace-write`. An edit
2451
+ * inside the tree is the diff guards' to judge. */
2452
+ function toolWrite(abs, ctx) {
2453
+ if (ctx.main && under(abs, ctx.tree) === null) {
2454
+ const p = writable(under(abs, ctx.main));
2455
+ if (p !== null) emit(ctx, "tree-leak", `tree-leak ${shownPath(p)}`);
2456
+ }
2457
+ const rel = ctx.ws ? under(abs, ctx.ws.roots) : null;
2458
+ if (rel === null || rel === ctx.ws.report) return;
2459
+ const kind = ctx.main ? "tree-leak" : "workspace-write";
2460
+ emit(ctx, kind, `${kind} ${workspacePath(ctx.ws, rel)}`);
2461
+ }
2462
+
2463
+ /** Checks every tool call a worker transcript records (at most `SCAN_BOUND` bytes of it) by
2464
+ * literal rules: Bash commands for index writes, a run of the brief's `Suite:` command and the
2465
+ * paths they name, and tool writes for a tree leak or a planted change workspace. The working
2466
+ * tree is the git top-level of `cwd`, or `cwd` itself with a `transcript-tree-unresolved <git's
2467
+ * first error line>` note when git names none. Malformed lines are skipped. Returns
2468
+ * `{ failures, notes, truncated }`: the FAIL and NOTE line bodies in scan order and whether the
2469
+ * bound cut the transcript, which the caller notes with the agent id; an unreadable transcript
2470
+ * gives no failures and the one note `transcript-unavailable unreadable`. With `mainRoot`, `cwd`
2471
+ * is a task's tree: an index write or a tool write under the main root outside the tree fails
2472
+ * `tree-leak`, and a command run outside the tree is noted `tree-cwd`. With `report`, the path
2473
+ * of the review package in `<root>/.freeharness/sdd/<change>/`, a tool write in that directory
2474
+ * other than to the report fails too, since the guard trusts the ledger and round copies there. */
3228
2475
  export function scanTranscript(file, brief, cwd, mainRoot, report) {
3229
2476
  const read = readBoundedNoFollow(file, SCAN_BOUND, false);
3230
2477
  if (read === null) return { failures: [], notes: ["transcript-unavailable unreadable"] };
@@ -3237,25 +2484,13 @@ export function scanTranscript(file, brief, cwd, mainRoot, report) {
3237
2484
  notes.push(`transcript-tree-unresolved ${scanDetail(String(err instanceof Error ? err.message : err).split("\n")[0])}`);
3238
2485
  }
3239
2486
  const suite = brief && brief.header && typeof brief.header.Suite === "string" ? brief.header.Suite.replace(/\s+/g, " ").trim() : "";
3240
- const ctx = {
3241
- tree,
3242
- suite,
3243
- failures: [],
3244
- notes,
3245
- seen: new Set(),
3246
- counts: new Map(),
3247
- capped: 0,
3248
- budget: { fs: SCAN_FS_BUDGET, chars: SCAN_PATH_BUDGET },
3249
- dirs: new Map(),
3250
- resolved: new Map(),
3251
- inside: new Map(),
3252
- folded: new Set(),
3253
- trees: new Map(),
3254
- targets: new Map(),
3255
- };
3256
- // The main root seen as a second working tree, sharing the budgets and the lookup cache.
3257
- if (mainRoot !== undefined) ctx.main = { tree: realDir(mainRoot, ctx), budget: ctx.budget, notes, dirs: ctx.dirs, inside: new Map(), folded: new Set(), trees: new Map() };
3258
- if (report !== undefined) ctx.ws = changeWorkspace(report, ctx);
2487
+ const scope = brief && Array.isArray(brief.scope) ? brief.scope : [];
2488
+ const ctx = { tree: spellings(tree), treeDir: tree, suite, scope, failures: [], notes, seen: new Set(), counts: new Map(), capped: 0 };
2489
+ if (mainRoot !== undefined) {
2490
+ ctx.main = spellings(mainRoot);
2491
+ ctx.mainDir = mainRoot;
2492
+ }
2493
+ if (report !== undefined) ctx.ws = changeWorkspace(report);
3259
2494
  const text = read.cut ? read.text.slice(0, read.text.lastIndexOf("\n") + 1) : read.text;
3260
2495
  for (const raw of text.split("\n")) {
3261
2496
  let rec;
@@ -3266,25 +2501,19 @@ export function scanTranscript(file, brief, cwd, mainRoot, report) {
3266
2501
  }
3267
2502
  const content = rec && rec.type === "assistant" && rec.message ? rec.message.content : null;
3268
2503
  if (!Array.isArray(content)) continue;
3269
- const dir = typeof rec.cwd === "string" && path.isAbsolute(rec.cwd) ? rec.cwd : tree;
3270
- // A real starting directory keeps text-only resolution, once the fs budget is spent, comparable
3271
- // with the real tree; one past PATH_MAX no shell could stand in is unknown.
3272
- const here = dir.length > SCAN_PATH_MAX ? null : realDir(dir, ctx);
2504
+ const dir = typeof rec.cwd === "string" && path.isAbsolute(rec.cwd) ? path.resolve(rec.cwd) : tree;
2505
+ const here = dir.length > SCAN_PATH_MAX ? null : dir;
3273
2506
  for (const part of content) {
3274
2507
  if (!part || part.type !== "tool_use" || !part.input) continue;
3275
- if (part.name === "Bash" && typeof part.input.command === "string") scanShell(part.input.command, here, ctx, 0);
2508
+ if (part.name === "Bash" && typeof part.input.command === "string") scanBash(part.input.command, here, ctx);
3276
2509
  const target = part.name === "NotebookEdit" ? (part.input.notebook_path ?? part.input.file_path) : part.input.file_path;
3277
2510
  if ((ctx.main || ctx.ws) && ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(part.name) && typeof target === "string" && target.length <= SCAN_PATH_MAX) {
3278
- const abs = resolveDir(here, target, ctx);
3279
- // Only for a leak or a planted workspace file: an edit inside the tree is the tool's job.
3280
- if (abs !== null && ctx.main) treeWrite(abs, ctx);
3281
- if (abs !== null) plantedWrite(abs, ctx, "workspace-write", true);
2511
+ if (path.isAbsolute(target) || here !== null) toolWrite(path.resolve(here ?? path.sep, target), ctx);
3282
2512
  }
3283
2513
  }
3284
2514
  }
3285
- if (ctx.capped > 0) ctx.notes.push(`bash-write-capped ${ctx.capped}`);
3286
- if (read.cut) ctx.notes.push("transcript-truncated");
3287
- return { failures: ctx.failures, notes: ctx.notes };
2515
+ if (ctx.capped > 0) ctx.notes.push(`scan-capped ${ctx.capped}`);
2516
+ return { failures: ctx.failures, notes: ctx.notes, truncated: read.cut };
3288
2517
  }
3289
2518
 
3290
2519
  /** Usage text: one line per subcommand with its arguments, in order. `commit` runs the repo's
@@ -3518,6 +2747,25 @@ function leavesRoot(root, dir) {
3518
2747
  return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
3519
2748
  }
3520
2749
 
2750
+ /** The main worktree of the checkout at `top`, by its common git directory; `{ error }` when no
2751
+ * main tree holds that directory. */
2752
+ function mainWorktree(top) {
2753
+ let gitDir;
2754
+ let commonDir;
2755
+ try {
2756
+ [gitDir, commonDir] = runGit(["rev-parse", "--git-dir", "--git-common-dir"], top)
2757
+ .split("\n")
2758
+ .slice(0, 2)
2759
+ .map((p) => fs.realpathSync(path.resolve(top, p.trim())));
2760
+ } catch (err) {
2761
+ return { error: `common git directory unresolved (${quoted(String(err instanceof Error ? err.message : err).split("\n")[0])})` };
2762
+ }
2763
+ if (gitDir === commonDir) return { root: top };
2764
+ const main = path.dirname(commonDir);
2765
+ const info = treeInfo(main, fs.realpathSync(top));
2766
+ return info.ok ? { root: main } : { error: `no main worktree holds ${quoted(commonDir)} (${quoted(info.reason)})` };
2767
+ }
2768
+
3521
2769
  /** Runs `brief-init`: the skeleton of one task's brief, never written over an existing brief. */
3522
2770
  function runBriefInit(rest) {
3523
2771
  if (rest.length !== 2) return null;
@@ -3525,12 +2773,21 @@ function runBriefInit(rest) {
3525
2773
  const fail = (e) => report([e], "brief-init");
3526
2774
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(change)) return fail(`task-shape change ${quoted(change)} is not kebab-case`);
3527
2775
  if (!BRIEF_TASK_RE.test(task)) return fail(`task-shape task ${quoted(task)} is not a dotted task id or final`);
3528
- let root = process.cwd();
2776
+ // The lead and hooks read the main tree's workspace; Base stays this checkout's HEAD.
2777
+ let here = process.cwd();
2778
+ let root = here;
2779
+ let top = null;
3529
2780
  try {
3530
- root = git(["rev-parse", "--show-toplevel"], root);
2781
+ top = git(["rev-parse", "--show-toplevel"], here);
3531
2782
  } catch {
3532
2783
  // outside a repository the current directory is the project root
3533
2784
  }
2785
+ if (top !== null) {
2786
+ const main = mainWorktree(top);
2787
+ if (main.error) return fail(`tree-path ${quoted(top)}: ${main.error}`);
2788
+ here = top;
2789
+ root = main.root;
2790
+ }
3534
2791
  const tasksFile = path.join(root, "openspec", "changes", change, "tasks.md");
3535
2792
  let tasks;
3536
2793
  try {
@@ -3545,7 +2802,7 @@ function runBriefInit(rest) {
3545
2802
  if (!template) return fail(`template-missing ${quoted(templateFile)}`);
3546
2803
  let base = null;
3547
2804
  try {
3548
- base = git(["rev-parse", "HEAD"], root);
2805
+ base = git(["rev-parse", "HEAD"], here);
3549
2806
  } catch {
3550
2807
  // no commit yet: Base stays a placeholder
3551
2808
  }
@@ -3734,6 +2991,7 @@ function runHandoff(rest) {
3734
2991
  for (const p of found.paths) {
3735
2992
  const scan = scanTranscript(p, briefInfo, work, main ?? undefined, file);
3736
2993
  notes.push(...scan.notes);
2994
+ if (scan.truncated) notes.push(`transcript-truncated ${quoted(agent)}`);
3737
2995
  for (const f of scan.failures) failures.add(f);
3738
2996
  }
3739
2997
  // The scan quotes what it shows; this pass keeps every transcript-derived line safe whatever
@@ -4419,7 +3677,8 @@ function treeRoundArgs(rest) {
4419
3677
  }
4420
3678
 
4421
3679
  /** Lines of a regular file read without following a symlink; 0 for anything else, so an
4422
- * untracked link or FIFO never blocks or leaves the tree. */
3680
+ * untracked link or FIFO never blocks or leaves the tree; 0 when the first 8 KiB hold a NUL
3681
+ * byte, as numstat counts a binary file. */
4423
3682
  function untrackedLines(abs) {
4424
3683
  let fd;
4425
3684
  try {
@@ -4431,8 +3690,12 @@ function untrackedLines(abs) {
4431
3690
  if (!fs.fstatSync(fd).isFile()) return 0;
4432
3691
  const buf = Buffer.allocUnsafe(64 * 1024);
4433
3692
  let n = 0;
3693
+ let seen = 0;
4434
3694
  let last = 0x0a;
4435
3695
  for (let got; (got = fs.readSync(fd, buf, 0, buf.length, null)) > 0; last = buf[got - 1]) {
3696
+ const sniff = Math.min(got, Math.max(0, BINARY_SNIFF_BYTES - seen));
3697
+ if (sniff > 0 && buf.subarray(0, sniff).includes(0)) return 0;
3698
+ seen += got;
4436
3699
  for (let i = 0; i < got; i++) if (buf[i] === 0x0a) n++;
4437
3700
  }
4438
3701
  return last === 0x0a ? n : n + 1;