@davesheffer/hunch 1.10.0 → 1.10.2

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/mcp/roots.js CHANGED
@@ -5,10 +5,26 @@
5
5
  * another workspace or linked worktree. MCP roots are the client-neutral protocol
6
6
  * mechanism for following that change.
7
7
  */
8
- import { statSync } from "node:fs";
8
+ import { realpathSync, statSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
12
+ /** One canonical spelling per directory. `findRoot` only resolve()s, but a
13
+ * client's roots/list URI can spell the same repo differently — VS Code sends
14
+ * a lowercase drive letter (`c:\…`) while the spawn cwd has `C:\…`, and Git
15
+ * for Windows can surface 8.3/short names. Raw string comparison then treats
16
+ * ONE repo as different roots: a full re-prepare (new store + reindex) on
17
+ * every connect, or a false "multiple roots equally plausible" refusal
18
+ * (issue #54). realpathSync.native returns the on-disk spelling for all of
19
+ * these; fall back to the input when the path is transiently unreadable. */
20
+ export function canonicalRootPath(root) {
21
+ try {
22
+ return realpathSync.native(root);
23
+ }
24
+ catch {
25
+ return root;
26
+ }
27
+ }
12
28
  function toPath(uri) {
13
29
  if (!uri.startsWith("file:"))
14
30
  return "";
@@ -44,12 +60,12 @@ export function resolveActiveRoot(rootUris, fallbackCwd) {
44
60
  const start = rootStart(toPath(uri));
45
61
  if (!start)
46
62
  continue;
47
- const root = findRoot(start);
63
+ const root = canonicalRootPath(findRoot(start));
48
64
  if (!candidates.includes(root))
49
65
  candidates.push(root);
50
66
  }
51
67
  if (!candidates.length)
52
- return findRoot(fallbackCwd);
68
+ return canonicalRootPath(findRoot(fallbackCwd));
53
69
  if (candidates.length === 1)
54
70
  return candidates[0];
55
71
  const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { z } from "zod";
13
13
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
- import { resolveActiveRoot } from "./roots.js";
14
+ import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
15
15
  import { HunchStore } from "../store/hunchStore.js";
16
16
  import { selectEmbedder } from "../store/embedder.js";
17
17
  import { decisionId, findingId } from "../core/ids.js";
@@ -278,8 +278,11 @@ export function buildServerWithRootControl(initialRoot) {
278
278
  let pendingScheduled = false;
279
279
  let closed = false;
280
280
  const activateRoot = (next) => {
281
- const canonical = findRoot(next);
282
- if (canonical === root)
281
+ // canonicalRootPath: a case/8.3 spelling difference must not read as a
282
+ // DIFFERENT repo — that closed the live store and re-prepared everything
283
+ // on every same-repo client connect (issue #54).
284
+ const canonical = canonicalRootPath(findRoot(next));
285
+ if (canonical === canonicalRootPath(root))
283
286
  return;
284
287
  const prepared = prepareRoot(canonical, explicitOverlay, true);
285
288
  const previous = store;
@@ -386,12 +389,18 @@ export function buildServerWithRootControl(initialRoot) {
386
389
  if (teamAdvertised && !matchesStartupTeamRoute()) {
387
390
  return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
388
391
  }
389
- try {
390
- if (store.sourceStamp() !== indexedSourceStamp)
391
- refreshIndex();
392
- }
393
- catch { /* corrupt/churning local source — serve the last durable indexed view */ }
394
392
  }
393
+ // Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
394
+ // hook in another terminal writes JSON that mtime-invalidated loadAll sees
395
+ // immediately, while the SQLite FTS/graph index this long-lived process
396
+ // serves would stay frozen at startup — split-brain answers within one
397
+ // session (JSON-backed tools fresh, query/structure/dependents stale)
398
+ // until restart (issue #49).
399
+ try {
400
+ if (store.sourceStamp() !== indexedSourceStamp)
401
+ refreshIndex();
402
+ }
403
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
395
404
  const result = await callback(...args);
396
405
  if (teamAdvertised && !matchesStartupTeamRoute()) {
397
406
  return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
@@ -1518,6 +1527,7 @@ export function buildServerWithRootControl(initialRoot) {
1518
1527
  server,
1519
1528
  getRoot: () => root,
1520
1529
  setRoot,
1530
+ cancelPendingRoot: () => { pendingRoot = null; },
1521
1531
  };
1522
1532
  }
1523
1533
  /** Back-compatible server construction for tests and callers that do not need
@@ -1546,6 +1556,14 @@ export function wireClientRoots(control, fallback) {
1546
1556
  return;
1547
1557
  const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
1548
1558
  if (!next) {
1559
+ // Cancel a swap parked by an EARLIER generation. setRoot defers when a tool
1560
+ // request is in flight; if the client's advertised set has since become
1561
+ // ambiguous, applying that stale swap once the request drains would re-home
1562
+ // to a repo the client no longer unambiguously advertises — writing (and
1563
+ // auto-committing) a capture into the wrong repository, which is precisely
1564
+ // what this refusal exists to prevent. Without this the message below was
1565
+ // also a lie: the root did NOT stay put.
1566
+ control.cancelPendingRoot();
1549
1567
  console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
1550
1568
  return;
1551
1569
  }
@@ -58,6 +58,12 @@ export function planCompaction(input, opts) {
58
58
  continue; // d is being removed → its references don't count
59
59
  if (d.supersedes)
60
60
  refDec.add(d.supersedes);
61
+ // superseded_by is a reference too: supersedeIn() sets old.superseded_by
62
+ // without requiring the successor's `supersedes`, so removing a later-
63
+ // rejected successor would leave the surviving record with a dangling
64
+ // pointer AND permanently non-live for its topic (issue #36).
65
+ if (d.superseded_by)
66
+ refDec.add(d.superseded_by);
61
67
  if (d.caused_by_bug)
62
68
  refBug.add(d.caused_by_bug);
63
69
  }
@@ -760,11 +760,14 @@ export class HunchStore {
760
760
  const symbols = this.recs("symbols");
761
761
  const components = this.recs("components");
762
762
  const asOf = opts.asOf;
763
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
763
+ // pathRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
764
+ // so an unanchored suffix pulled unrelated files' records into why()/the
765
+ // pre-edit grounding block (issue #32). Segment-anchored matching only.
766
+ const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathRelated(s.file, target));
764
767
  const symIds = new Set(matchedSymbols.map((s) => s.id));
765
768
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
766
769
  const isPath = target.includes("/") || target.includes(".");
767
- const fileMatch = (files) => files.some((f) => f === target || (isPath && (f.endsWith(target) || target.endsWith(f))) || fileSet.has(f));
770
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && pathRelated(f, target)) || fileSet.has(f));
768
771
  return {
769
772
  target,
770
773
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -1525,7 +1528,9 @@ const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
1525
1528
  const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
1526
1529
  function numEnv(name, dflt) {
1527
1530
  const v = Number(process.env[name]);
1528
- return Number.isFinite(v) && v > 0 ? v : dflt;
1531
+ // >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
1532
+ // a stream); rejecting it silently re-enabled the default weight (issue #33).
1533
+ return Number.isFinite(v) && v >= 0 ? v : dflt;
1529
1534
  }
1530
1535
  /** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
1531
1536
  * view (byteOffset != 0) writes only its slice, not the whole backing buffer.
@@ -14,6 +14,9 @@ import { writeFileAtomic } from "../core/io.js";
14
14
  * constraints) are one file per record so they're cleanly reviewable in PRs. */
15
15
  const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
16
16
  const encode = (v) => JSON.stringify(v, null, 2) + "\n";
17
+ // Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
18
+ // same idiom as core/io.ts's rename backoff.
19
+ const RMW_LOCK_WAITER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
17
20
  /** Curated entities are intentionally small, human-reviewable records. Symbols
18
21
  * and edges are dense indexes, so they get a much larger but still finite cap. */
19
22
  export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
@@ -389,6 +392,14 @@ export class JsonStore {
389
392
  const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
390
393
  if (text === null)
391
394
  continue;
395
+ // A 0-byte per-record file is a merge-driver TOMBSTONE, not a record:
396
+ // git cannot delete through a merge driver, so "both sides deleted" is
397
+ // materialized as an empty %A (issue #37). Human-approved refinement of
398
+ // con_947c578b2c's boundary (2026-08-04): an empty file holds no record
399
+ // to migrate or drop, and Hunch's own atomic writes (con_902759b3dc)
400
+ // never produce one — emptiness is unambiguous, so no warning.
401
+ if (text.trim() === "")
402
+ continue;
392
403
  raw = JSON.parse(text);
393
404
  }
394
405
  catch (e) {
@@ -403,6 +414,49 @@ export class JsonStore {
403
414
  }
404
415
  return out;
405
416
  }
417
+ /** Cross-process mutex for single-file index read-modify-write (issue #35).
418
+ * The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
419
+ * two unsynchronized RMWs over index.json each read the same base array and the
420
+ * second rename silently erases the first's record. `mkdirSync` is the atomic
421
+ * acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
422
+ * against a live contender we wait briefly and then proceed WITH a warning —
423
+ * never worse than the historical lockless behavior, and capture paths must not
424
+ * start throwing on lock contention. */
425
+ withSingleFileLock(kind, directory, fn) {
426
+ const lock = join(directory.lexical, ".rmw-lock");
427
+ const deadline = Date.now() + 2_000;
428
+ for (;;) {
429
+ try {
430
+ mkdirSync(lock);
431
+ break;
432
+ }
433
+ catch {
434
+ try {
435
+ if (Date.now() - lstatSync(lock).mtimeMs > 10_000) {
436
+ rmSync(lock, { recursive: true, force: true }); // no live spawn holds a lock this old
437
+ continue;
438
+ }
439
+ }
440
+ catch {
441
+ continue; /* vanished between attempts — retry the acquire */
442
+ }
443
+ if (Date.now() >= deadline) {
444
+ console.warn(`[hunch] proceeding without the ${kind} index lock (still held: ${lock})`);
445
+ return fn();
446
+ }
447
+ Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
448
+ }
449
+ }
450
+ try {
451
+ return fn();
452
+ }
453
+ finally {
454
+ try {
455
+ rmSync(lock, { recursive: true, force: true });
456
+ }
457
+ catch { /* stale-takeover reclaims it */ }
458
+ }
459
+ }
406
460
  /** Write a single record (validated) to its JSON file / into the index array. */
407
461
  put(kind, record) {
408
462
  const schema = SCHEMAS[kind];
@@ -416,11 +470,14 @@ export class JsonStore {
416
470
  // record can't silently drop schema-invalid / future-schema siblings — the
417
471
  // same reason delete() reads raw. Keep the index sorted by id (stable diff,
418
472
  // and agrees with the merge driver so a re-index after a merge is a no-op).
419
- const f = this.fileFor(kind, validated.id);
420
- const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
421
- arr.push(validated);
422
- arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
423
- this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
473
+ // Locked: the read-modify-write below is what issue #35 races.
474
+ this.withSingleFileLock(kind, directory, () => {
475
+ const f = this.fileFor(kind, validated.id);
476
+ const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
477
+ arr.push(validated);
478
+ arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
479
+ this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
480
+ });
424
481
  }
425
482
  else {
426
483
  this.writeContainedFile(directory, this.fileFor(kind, validated.id), encode(validated), this.maxBytes(kind));
@@ -446,19 +503,27 @@ export class JsonStore {
446
503
  this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
447
504
  return;
448
505
  }
449
- // One file per record: preflight EVERY existing JSON file before deleting
506
+ // One file per record: preflight EVERY existing JSON file before touching
450
507
  // any, so one malicious symlink cannot cause a partially-cleared store.
451
508
  const existing = this.jsonFileNames(kind);
452
509
  for (const name of existing) {
453
510
  this.validateExistingFile(directory, join(directory.lexical, name), this.maxBytes(kind));
454
511
  }
455
- for (const name of existing) {
456
- this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
457
- }
512
+ // WRITE-FIRST, delete-stale-LAST (issue #30). The old delete-all-then-rewrite
513
+ // sequence had a crash window in which the kind directory held nothing — and
514
+ // `hunch private --migrate` runs replaceAll on the OVERLAY, so that window
515
+ // covered private-only records existing nowhere else. Now a crash mid-write
516
+ // leaves old ∪ new (same id → same file, so no duplicates), and a crash
517
+ // mid-delete leaves only stale extras — no state loses records.
518
+ const keep = new Set(validated.map((r) => `${r.id}.json`));
458
519
  for (const r of validated) {
459
520
  const id = r.id;
460
521
  this.writeContainedFile(directory, this.fileFor(kind, id), encode(r), this.maxBytes(kind));
461
522
  }
523
+ for (const name of existing) {
524
+ if (!keep.has(name))
525
+ this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
526
+ }
462
527
  }
463
528
  /** Read a single-file index as a raw array (no validation). Missing/empty → [].
464
529
  * A non-empty file that fails to parse THROWS — we must never silently treat a
@@ -482,6 +547,29 @@ export class JsonStore {
482
547
  get(kind, id) {
483
548
  return this.loadAll(kind).find((r) => r.id === id);
484
549
  }
550
+ /** On-disk record count, independent of validation: per-record kinds count
551
+ * every non-tombstone .json file (a 0-byte merge tombstone is an intentional
552
+ * absence), single-file kinds count raw array entries (a corrupt index file
553
+ * throws readRawArray's own actionable refusal). Lets a caller about to
554
+ * DELETE the kind — `hunch private --migrate` — prove the validating loader
555
+ * dropped nothing first, instead of silently destroying the records loadAll
556
+ * skipped (issue #29, the same never-silently-drop contract as
557
+ * con_947c578b2c). */
558
+ rawRecordCount(kind) {
559
+ const directory = this.safeKindDirectory(kind, false);
560
+ if (!directory)
561
+ return 0;
562
+ const single = SINGLE_FILE[kind];
563
+ if (single)
564
+ return this.readRawArray(kind, directory, join(directory.lexical, single)).length;
565
+ let count = 0;
566
+ for (const name of this.jsonFileNames(kind)) {
567
+ const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
568
+ if (text !== null && text.trim() !== "")
569
+ count++;
570
+ }
571
+ return count;
572
+ }
485
573
  /** Remove a record (used by the curate/reject flow). Returns true if removed.
486
574
  * For single-file kinds we operate on the RAW JSON array (not the validating
487
575
  * loader) so deleting one record can't silently drop schema-invalid siblings. */
@@ -491,16 +579,20 @@ export class JsonStore {
491
579
  const directory = this.safeKindDirectory(kind, false);
492
580
  if (!directory)
493
581
  return false;
494
- const f = this.fileFor(kind, "index");
495
- const arr = this.readRawArray(kind, directory, f);
496
- if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
497
- return false;
498
- const next = arr.filter((r) => r?.id !== id);
499
- if (next.length === arr.length)
500
- return false;
501
- this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
502
- this.invalidate(kind);
503
- return true;
582
+ // Locked like put(): an unsynchronized delete racing a concurrent put
583
+ // over the same index would resurrect or drop records (issue #35).
584
+ return this.withSingleFileLock(kind, directory, () => {
585
+ const f = this.fileFor(kind, "index");
586
+ const arr = this.readRawArray(kind, directory, f);
587
+ if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
588
+ return false;
589
+ const next = arr.filter((r) => r?.id !== id);
590
+ if (next.length === arr.length)
591
+ return false;
592
+ this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
593
+ this.invalidate(kind);
594
+ return true;
595
+ });
504
596
  }
505
597
  this.assertSafeRecordId(id);
506
598
  const directory = this.safeKindDirectory(kind, false);
@@ -21,6 +21,18 @@ export function movePublicMemoryToPrivate(pub, priv) {
21
21
  let total = 0;
22
22
  for (const kind of ENTITY_KINDS) {
23
23
  const pubRecs = pub.loadAll(kind);
24
+ // The validating loader SKIPS corrupt/invalid/future-schema records with a
25
+ // warning — but the CLI empties the public store right after this returns,
26
+ // which would silently DELETE exactly those skipped records (and then
27
+ // untrack + gitignore their only git history). Refuse instead: prove every
28
+ // on-disk record actually loaded before anything becomes deletable
29
+ // (issue #29). A kind whose only records are invalid also stops here, so
30
+ // the "0 loaded → skip merge" path below can never precede a wipe.
31
+ const rawCount = pub.rawRecordCount(kind);
32
+ if (rawCount !== pubRecs.length) {
33
+ throw new Error(`refusing to migrate ${kind}: ${rawCount - pubRecs.length} on-disk record(s) failed to load (see the warnings above) `
34
+ + `and would be deleted without ever reaching the overlay. Fix or remove them, then re-run \`hunch private --migrate\`.`);
35
+ }
24
36
  if (pubRecs.length === 0)
25
37
  continue;
26
38
  const privRecs = priv.loadAll(kind);
@@ -24,7 +24,7 @@
24
24
  * Every provider returns the same shape so the rest of the system never knows
25
25
  * (or cares) which one ran.
26
26
  */
27
- import { spawn } from "node:child_process";
27
+ import { execFile, spawn } from "node:child_process";
28
28
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
29
29
  import { isIP } from "node:net";
30
30
  import { tmpdir } from "node:os";
@@ -66,6 +66,20 @@ export function pexecIn(cmd, args, opts = {}) {
66
66
  let err = "";
67
67
  let outLen = 0;
68
68
  let settled = false;
69
+ // Windows spawns through a cmd.exe wrapper (shell:true), and child.kill()
70
+ // terminates only that wrapper — the actual agent CLI survives as an orphan,
71
+ // still burning the user's subscription after every timeout, accumulating
72
+ // with each post-commit hook fire (issue #44). taskkill /T fells the tree.
73
+ // Best-effort by design: the wrapper kill below still runs either way.
74
+ const killTree = () => {
75
+ if (IS_WIN && child.pid) {
76
+ try {
77
+ execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
78
+ }
79
+ catch { /* taskkill unavailable — fall through to the wrapper kill */ }
80
+ }
81
+ child.kill();
82
+ };
69
83
  const done = (fn) => {
70
84
  if (settled)
71
85
  return;
@@ -76,7 +90,7 @@ export function pexecIn(cmd, args, opts = {}) {
76
90
  };
77
91
  const timer = opts.timeout
78
92
  ? setTimeout(() => {
79
- child.kill();
93
+ killTree();
80
94
  done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
81
95
  }, opts.timeout)
82
96
  : null;
@@ -84,7 +98,7 @@ export function pexecIn(cmd, args, opts = {}) {
84
98
  child.stdout.on("data", (d) => {
85
99
  outLen += d.length;
86
100
  if (outLen > max) {
87
- child.kill();
101
+ killTree();
88
102
  done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
89
103
  return;
90
104
  }
@@ -323,6 +337,18 @@ class ClaudeCliProvider extends PromptSynthProvider {
323
337
  const childEnv = { ...process.env };
324
338
  delete childEnv.ANTHROPIC_API_KEY;
325
339
  delete childEnv.ANTHROPIC_AUTH_TOKEN;
340
+ // Gateway ROUTING is metered per-token exactly like a raw API key: with
341
+ // CLAUDE_CODE_USE_BEDROCK/VERTEX set (common in enterprise shell profiles
342
+ // for interactive use), headless `claude -p` bills AWS/GCP on every
343
+ // significant commit — silently, from a post-commit hook (issue #39,
344
+ // con_2ce3f2a547). Strip the routing switches and their endpoint overrides
345
+ // so the CLI falls through to subscription auth here too.
346
+ delete childEnv.CLAUDE_CODE_USE_BEDROCK;
347
+ delete childEnv.CLAUDE_CODE_USE_VERTEX;
348
+ delete childEnv.ANTHROPIC_BEDROCK_BASE_URL;
349
+ delete childEnv.ANTHROPIC_VERTEX_BASE_URL;
350
+ delete childEnv.ANTHROPIC_VERTEX_PROJECT_ID;
351
+ delete childEnv.CLOUD_ML_REGION;
326
352
  // Single-shot text synthesis: no tools, no agentic loop. The prompt carries
327
353
  // all needed context inline, so run from a neutral cwd to avoid loading this
328
354
  // repo's own hunch MCP server / CLAUDE.md on every commit (cheaper, and no
package/dist/wiki/wiki.js CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { createHash } from "node:crypto";
30
30
  import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
31
- import { join, dirname } from "node:path";
31
+ import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
32
32
  import { writeFileAtomic } from "../core/io.js";
33
33
  import { compareCodeUnits } from "../core/canonicalOrder.js";
34
34
  import { hunchPaths, toPosixTarget } from "../core/paths.js";
@@ -36,15 +36,61 @@ import { isLive } from "../core/topics.js";
36
36
  import { scanRepoDocs } from "../core/docscan.js";
37
37
  import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
38
38
  import { assembleGraphData, renderGraphPage } from "./graph.js";
39
+ /** A wiki directory that is safe to join against the pages root: relative, POSIX,
40
+ * no trailing slash, and free of any "." / ".." / empty segment. `.` would alias
41
+ * the pages root itself (clobbering tracked files like README.md) and `..` writes
42
+ * the rendered graph — which for the private home is the FULL overlay union —
43
+ * outside the repository entirely. Returns undefined for anything unsafe. */
44
+ function validDir(d) {
45
+ if (!d)
46
+ return undefined;
47
+ const v = toPosixTarget(d).replace(/\/+$/, "");
48
+ if (!v || isAbsolute(v) || /^[a-zA-Z]:/.test(v))
49
+ return undefined;
50
+ if (v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
51
+ return undefined;
52
+ return v;
53
+ }
39
54
  /** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
40
- * a committed manifest key prefix, so it must hash identically on every OS. */
41
- const normDir = (d) => d ? toPosixTarget(d).replace(/\/+$/, "") || undefined : undefined;
55
+ * a committed manifest key prefix, so it must hash identically on every OS.
56
+ * An unsafe override THROWS rather than silently falling back, so a rejected flag
57
+ * can never quietly write somewhere else. */
58
+ const normDir = (d) => {
59
+ if (d === undefined)
60
+ return undefined;
61
+ const v = validDir(d);
62
+ if (!v) {
63
+ throw new Error(`refusing --dir ${JSON.stringify(d)}: the wiki directory must be a relative path inside the repository (no "." or ".." segments, not absolute).`);
64
+ }
65
+ return v;
66
+ };
67
+ /** Resolve a manifest page key to an absolute path INSIDE this home's page
68
+ * directory, or null when it escapes.
69
+ *
70
+ * Both inputs that reach the join are untrusted. `.hunch/wiki-manifest.json` is a
71
+ * COMMITTED file (so CI can gate on it), which makes every page KEY inside it
72
+ * PR- and merge-influenceable — and `wikiStatus` classifies any key no current
73
+ * artifact claims as an orphan to be deleted. Without this check a key like
74
+ * "../../id_rsa" turned a plain `hunch wiki` into arbitrary file deletion, with
75
+ * `hunch drift` politely instructing the maintainer to run it. Re-checked at BOTH
76
+ * the write and the delete site, so a manifest written before this guard existed
77
+ * still cannot escape. */
78
+ function containedPagePath(home, page) {
79
+ if (!page || page.includes("\0") || isAbsolute(page) || /^[a-zA-Z]:/.test(page))
80
+ return null;
81
+ const baseDir = resolve(join(home.pagesRoot, home.dir));
82
+ const abs = resolve(join(home.pagesRoot, ...page.split("/")));
83
+ const rel = relative(baseDir, abs);
84
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
85
+ return null;
86
+ return abs;
87
+ }
42
88
  export function publicHome(root, dirOverride) {
43
89
  const manifestPath = join(hunchPaths(root).hunch, "wiki-manifest.json");
44
90
  return {
45
91
  kind: "public",
46
92
  pagesRoot: root,
47
- dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
93
+ dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
48
94
  manifestPath,
49
95
  source: "public",
50
96
  };
@@ -60,7 +106,7 @@ export function privateHome(store, dirOverride) {
60
106
  return {
61
107
  kind: "private",
62
108
  pagesRoot: dirname(store.privateDir),
63
- dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
109
+ dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
64
110
  manifestPath,
65
111
  source: "all",
66
112
  };
@@ -515,6 +561,11 @@ export function wikiStatus(store, home, srcRoot) {
515
561
  for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
516
562
  if (expected.has(page))
517
563
  continue;
564
+ // The manifest is committed, so a key is attacker-influenceable through an
565
+ // ordinary PR — and reaching this list means "delete this file". Only a key
566
+ // that resolves inside this home's page directory may be classified at all.
567
+ if (!containedPagePath(home, page))
568
+ continue;
518
569
  (p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
519
570
  }
520
571
  return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, graph, repoWide, orphans };
@@ -556,7 +607,10 @@ export async function generateWiki(store, srcRoot, home, opts) {
556
607
  /** Written-bytes ledger — the hand-edit tripwire recorded per page. */
557
608
  const bytesByPage = new Map();
558
609
  const put = (page, content) => {
559
- writeFileAtomic(join(home.pagesRoot, ...page.split("/")), content);
610
+ const abs = containedPagePath(home, page);
611
+ if (!abs)
612
+ throw new Error(`refusing to write wiki page ${JSON.stringify(page)}: it resolves outside ${join(home.pagesRoot, home.dir)}`);
613
+ writeFileAtomic(abs, content);
560
614
  bytesByPage.set(page, sha16(content));
561
615
  written.push(page);
562
616
  };
@@ -614,8 +668,16 @@ export async function generateWiki(store, srcRoot, home, opts) {
614
668
  const removed = [];
615
669
  for (const [pages, why] of [[status.orphans, "component gone"], [status.adoptionOrphans, "original healed or removed — copy retired"]]) {
616
670
  for (const page of pages) {
671
+ // Re-assert containment at the DELETE site too: wikiStatus already filters
672
+ // escaping manifest keys, but this is the primitive that removes files, so it
673
+ // must not depend on an upstream filter having run.
674
+ const abs = containedPagePath(home, page);
675
+ if (!abs) {
676
+ log(` ⚠ ${page} — refusing to remove a manifest entry that resolves outside ${join(home.pagesRoot, home.dir)}`);
677
+ continue;
678
+ }
617
679
  try {
618
- rmSync(join(home.pagesRoot, ...page.split("/")), { force: true });
680
+ rmSync(abs, { force: true });
619
681
  }
620
682
  catch {
621
683
  /* best effort */
@@ -627,8 +689,26 @@ export async function generateWiki(store, srcRoot, home, opts) {
627
689
  if (written.length || removed.length) {
628
690
  const pages = {};
629
691
  const entry = (page, component, hash, state) => {
630
- const keep = state === "fresh" ? prior?.pages[page] : undefined;
631
- pages[page] = keep ?? { component, hash, generated: opts.now, bytes: bytesByPage.get(page) };
692
+ const prev = prior?.pages[page];
693
+ const justWritten = bytesByPage.get(page);
694
+ // Carry the prior entry forward ONLY for a fresh page this run did not rewrite.
695
+ // A full regen (`only: "all"`) rewrites every page, so reusing the prior entry
696
+ // there recorded the OLD bytes against freshly written content — making the
697
+ // hand-edit tripwire fire on Hunch's own output, so `hunch wiki --check` exited
698
+ // 1 immediately after a successful `hunch wiki` and the tripwire's signal for a
699
+ // REAL hand edit was destroyed.
700
+ if (state === "fresh" && justWritten === undefined && prev) {
701
+ pages[page] = prev;
702
+ return;
703
+ }
704
+ pages[page] = {
705
+ component,
706
+ hash,
707
+ // Keep the original stamp for an unchanged-but-rewritten page so a full regen
708
+ // stays byte-idempotent in git.
709
+ generated: state === "fresh" && prev ? prev.generated : opts.now,
710
+ bytes: justWritten ?? prev?.bytes,
711
+ };
632
712
  };
633
713
  for (const e of status.entries)
634
714
  entry(e.page, e.pack.component.id, e.hash, e.state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",