@davesheffer/hunch 1.41.5 → 1.42.0

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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +402 -154
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/docanchors.js +2 -1
  6. package/dist/core/eol.d.ts +10 -0
  7. package/dist/core/eol.js +23 -0
  8. package/dist/core/footprint.d.ts +15 -0
  9. package/dist/core/footprint.js +167 -0
  10. package/dist/core/groundingLag.d.ts +15 -0
  11. package/dist/core/groundingLag.js +27 -0
  12. package/dist/core/groundingMerge.js +8 -6
  13. package/dist/core/hookText.d.ts +4 -0
  14. package/dist/core/hookText.js +8 -0
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/shellwrites.d.ts +7 -0
  18. package/dist/core/shellwrites.js +131 -0
  19. package/dist/core/siblingfix.d.ts +124 -0
  20. package/dist/core/siblingfix.js +814 -0
  21. package/dist/core/taskReportHook.d.ts +1 -1
  22. package/dist/core/taskReportHook.js +19 -3
  23. package/dist/extractors/git.d.ts +31 -0
  24. package/dist/extractors/git.js +180 -1
  25. package/dist/extractors/k8sManifest.js +15 -42
  26. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  27. package/dist/extractors/nativeTreeSitter.js +128 -30
  28. package/dist/integrations/claudemd.d.ts +20 -2
  29. package/dist/integrations/claudemd.js +79 -53
  30. package/dist/integrations/gitignore.js +2 -1
  31. package/dist/integrations/hooks.js +5 -4
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +34 -22
  34. package/dist/integrations/team.d.ts +24 -4
  35. package/dist/integrations/team.js +154 -16
  36. package/dist/integrations/worktree.d.ts +3 -2
  37. package/dist/integrations/worktree.js +7 -4
  38. package/dist/mcp/server.d.ts +489 -0
  39. package/dist/mcp/server.js +208 -62
  40. package/dist/mcp/taskReportTools.js +12 -9
  41. package/dist/mcp/toolset.d.ts +11 -1
  42. package/dist/mcp/toolset.js +28 -8
  43. package/dist/store/hunchStore.d.ts +25 -1
  44. package/dist/store/hunchStore.js +146 -21
  45. package/dist/store/jsonStore.js +25 -4
  46. package/package.json +1 -1
  47. package/server.json +2 -2
@@ -6,12 +6,13 @@
6
6
  * Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
7
7
  */
8
8
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
9
- import { basename, dirname, join, resolve } from "node:path";
9
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { writeFileAtomic } from "../core/io.js";
12
+ import { machineFile } from "../core/machine.js";
12
13
  import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
13
14
  import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
14
- import { canonicalRemoteUrl, gitNullDevice, mainWorktreeRoot, sameRemoteUrl } from "../extractors/git.js";
15
+ import { canonicalRemoteUrl, checkoutCommonDir, gitNullDevice, mainWorktreeRoot, sameFilesystemEntry, sameRemoteUrl } from "../extractors/git.js";
15
16
  import { HunchStore } from "../store/hunchStore.js";
16
17
  import { JsonStore } from "../store/jsonStore.js";
17
18
  import { ensureSharedOverlayPointer } from "./worktree.js";
@@ -43,8 +44,8 @@ export function teamSharedRef(team) {
43
44
  return team.shared_ref ?? DEFAULT_TEAM_REF;
44
45
  }
45
46
  /** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
46
- * (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
47
- * it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
47
+ * (possibly untrusted) repo it is attacker-controlled, and every consumer parses and
48
+ * compares it before the user's trust is checked. Without this gate a value like `--upload-pack=…` (argument
48
49
  * smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
49
50
  * merely opening a repo. Allow only credential-free https://, ssh://, git://,
50
51
  * scp-style git@host:path, and never anything that could parse as a Git flag. */
@@ -57,10 +58,15 @@ export function safeGitUrl(url) {
57
58
  // an inevitably incomplete list of token/password parameter names.
58
59
  if (/[?#]/.test(u))
59
60
  return null;
60
- // A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
61
- // local test remote. Safe: a local clone never executes hooks or remote helpers. The
62
- // file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
63
- if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
61
+ // A network path (`\\host\share`, `//host/share`, `\\?\UNC\…`, and any mix of the
62
+ // two separators) is not local: on Windows, merely resolving or comparing it contacts
63
+ // and authenticates to the named host. Reject every form before any consumer touches it.
64
+ if (/^[\\/]{2}/.test(u))
65
+ return null;
66
+ // A plain absolute path (POSIX / Windows drive) — a mounted team store or a local test
67
+ // remote. Safe: a local clone never executes hooks or remote helpers. The file:// URL
68
+ // FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
69
+ if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u))
64
70
  return u;
65
71
  // SCP syntax carries an SSH account name, not an embedded authentication secret.
66
72
  // Its deliberately narrow account/host grammar cannot encode a password delimiter.
@@ -461,12 +467,19 @@ function checkoutIsolatedEnv() {
461
467
  GIT_ATTR_NOSYSTEM: "1",
462
468
  };
463
469
  }
470
+ /** Bound for each post-clone validation spawn: 2s by default, widened (never
471
+ * narrowed) by HUNCH_TEAM_CLONE_TIMEOUT_MS so slow disks or loaded CI do not turn a
472
+ * valid clone into a validation failure. */
473
+ function teamValidationTimeout() {
474
+ const override = teamCloneTimeoutOverride();
475
+ return override === undefined ? 2_000 : Math.max(2_000, override);
476
+ }
464
477
  function exactCommit(root, revision, env) {
465
478
  const result = spawnSync("git", ["-C", root, "rev-parse", "--verify", `${revision}^{commit}`], {
466
479
  encoding: "utf8",
467
480
  stdio: ["ignore", "pipe", "ignore"],
468
481
  env,
469
- timeout: 2_000,
482
+ timeout: teamValidationTimeout(),
470
483
  });
471
484
  const oid = result.status === 0 ? result.stdout.trim() : "";
472
485
  return {
@@ -479,7 +492,7 @@ function exactTreeListing(root, oid, env) {
479
492
  encoding: "utf8",
480
493
  stdio: ["ignore", "pipe", "ignore"],
481
494
  env,
482
- timeout: 2_000,
495
+ timeout: teamValidationTimeout(),
483
496
  maxBuffer: 64 * 1024 * 1024,
484
497
  });
485
498
  return result.status === 0 && safeOverlayGitTreeListing(result.stdout) ? result.stdout : null;
@@ -489,7 +502,7 @@ function repositoryHasNoRefs(root, env) {
489
502
  encoding: "utf8",
490
503
  stdio: ["ignore", "pipe", "ignore"],
491
504
  env,
492
- timeout: 2_000,
505
+ timeout: teamValidationTimeout(),
493
506
  });
494
507
  return result.status === 0 && result.stdout.trim() === "";
495
508
  }
@@ -499,7 +512,7 @@ function treeAttributesAreSafe(root, listing, env) {
499
512
  encoding: "utf8",
500
513
  stdio: ["ignore", "pipe", "ignore"],
501
514
  env,
502
- timeout: 2_000,
515
+ timeout: teamValidationTimeout(),
503
516
  maxBuffer: 4 * 1024 * 1024,
504
517
  });
505
518
  return blob.status === 0 ? blob.stdout : null;
@@ -572,7 +585,7 @@ function materializeValidatedClone(team, teamRoot, overlayRoot, emptyHooks) {
572
585
  ], {
573
586
  stdio: "ignore",
574
587
  env,
575
- timeout: 5_000,
588
+ timeout: Math.max(5_000, teamCloneTimeoutOverride() ?? 0),
576
589
  });
577
590
  if (reset.status !== 0) {
578
591
  reportTeamCloneFailure("materialize-reset", reset);
@@ -594,6 +607,16 @@ function materializeValidatedClone(team, teamRoot, overlayRoot, emptyHooks) {
594
607
  }
595
608
  return safe ? { sharedRef, empty: false } : null;
596
609
  }
610
+ /** HUNCH_TEAM_CLONE_TIMEOUT_MS: a per-process override for every team-store clone
611
+ * and materialization timeout (default: the caller's own bound). For slow disks,
612
+ * networks, or heavily parallel CI; ignored unless a positive integer. */
613
+ export function teamCloneTimeoutOverride() {
614
+ const raw = process.env.HUNCH_TEAM_CLONE_TIMEOUT_MS?.trim();
615
+ if (!raw || !/^\d+$/.test(raw))
616
+ return undefined;
617
+ const value = Number(raw);
618
+ return value > 0 ? Math.min(600_000, value) : undefined;
619
+ }
597
620
  /** Clone a shared memory repository without checking out attacker-controlled
598
621
  * paths, validate its exact route/OID/tree/attributes, and only then publish the
599
622
  * fully materialized clone at `destination`. Failure removes both quarantine and
@@ -606,9 +629,10 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
606
629
  return null;
607
630
  }
608
631
  const requestedTimeout = opts.timeoutMs ?? 5_000;
609
- const timeoutMs = Number.isFinite(requestedTimeout)
632
+ const defaultTimeoutMs = Number.isFinite(requestedTimeout)
610
633
  ? Math.min(30_000, Math.max(1, Math.trunc(requestedTimeout)))
611
634
  : 5_000;
635
+ const timeoutMs = Math.max(defaultTimeoutMs, teamCloneTimeoutOverride() ?? 0);
612
636
  const parent = dirname(destination);
613
637
  const prefix = basename(destination);
614
638
  let stagedDest = "";
@@ -682,12 +706,120 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
682
706
  rmSync(destination, { recursive: true, force: true });
683
707
  }
684
708
  }
709
+ /** Local consent for team auto-wiring. team.json is COMMITTED, so its URL is chosen
710
+ * by whoever wrote the repository, not by this user, and wiring it means cloning that
711
+ * remote, reading its records as memory, and pushing captures to it. The consent lives
712
+ * OUTSIDE every repository (beside machine.json), keyed by this checkout's main
713
+ * worktree and the exact advertised URL: a changed URL needs fresh consent, and no
714
+ * repository content can grant it. */
715
+ const TEAM_TRUST_MAX_BYTES = 256 * 1024;
716
+ export function teamTrustFile() {
717
+ return join(dirname(machineFile()), "team-trust.json");
718
+ }
719
+ function teamTrustKey(root) {
720
+ // Anchored through checkoutCommonDir so a `.git` file cannot borrow another checkout's key.
721
+ const common = checkoutCommonDir(root);
722
+ return realpathSync(common && basename(common) === ".git" ? dirname(common) : root);
723
+ }
724
+ /** The parsed consent file. Absent → empty; unreadable or malformed → null, which
725
+ * callers treat as "nothing trusted" and writers refuse to clobber. */
726
+ function readTeamTrust(file) {
727
+ let raw;
728
+ try {
729
+ const stat = lstatSync(file);
730
+ if (!stat.isFile() || stat.size > TEAM_TRUST_MAX_BYTES)
731
+ return null;
732
+ raw = readFileSync(file, "utf8");
733
+ }
734
+ catch (error) {
735
+ return error.code === "ENOENT" ? { version: 1, stores: {} } : null;
736
+ }
737
+ try {
738
+ const value = JSON.parse(raw);
739
+ if (value.version !== 1 || !value.stores || typeof value.stores !== "object" || Array.isArray(value.stores))
740
+ return null;
741
+ return { version: 1, stores: value.stores };
742
+ }
743
+ catch {
744
+ return null;
745
+ }
746
+ }
747
+ export function isTeamStoreTrusted(root, team) {
748
+ try {
749
+ const entry = readTeamTrust(teamTrustFile())?.stores[teamTrustKey(root)];
750
+ return !!entry && typeof entry.shared_repo === "string" && entry.shared_repo === team.shared_repo;
751
+ }
752
+ catch {
753
+ return false;
754
+ }
755
+ }
756
+ /** Record this user's explicit consent to wire `root` to `team.shared_repo`. Returns an
757
+ * undo that removes only THIS checkout's key (restoring its prior entry if one existed),
758
+ * never the whole file byte-for-byte: another checkout's concurrent trust write must
759
+ * survive the undo. The file is deleted only if it ends up empty and did not exist
760
+ * before, so a caller can make consent conditional on a later step. */
761
+ export function trustTeamStore(root, team) {
762
+ const sharedRepo = safeGitUrl(team.shared_repo);
763
+ if (!sharedRepo)
764
+ throw new Error("refusing to trust an unsafe team repository URL");
765
+ const file = teamTrustFile();
766
+ const current = readTeamTrust(file);
767
+ if (!current)
768
+ throw new Error(`refusing to overwrite unreadable team trust file: ${file}`);
769
+ // readTeamTrust proved the file is absent or a plain, bounded, parseable file.
770
+ const fileExistedBefore = existsSync(file);
771
+ const key = teamTrustKey(root);
772
+ const priorEntry = current.stores[key];
773
+ current.stores[key] = { shared_repo: sharedRepo, trusted_at: new Date().toISOString() };
774
+ mkdirSync(dirname(file), { recursive: true });
775
+ writeFileAtomic(file, JSON.stringify(current, null, 2) + "\n");
776
+ return () => {
777
+ const latest = readTeamTrust(file);
778
+ if (!latest)
779
+ return; // file went missing or unreadable out from under us: nothing safe to undo
780
+ if (priorEntry)
781
+ latest.stores[key] = priorEntry;
782
+ else
783
+ delete latest.stores[key];
784
+ if (Object.keys(latest.stores).length === 0 && !fileExistedBefore) {
785
+ rmSync(file, { force: true });
786
+ return;
787
+ }
788
+ writeFileAtomic(file, JSON.stringify(latest, null, 2) + "\n");
789
+ };
790
+ }
791
+ /** Whether this user consented to `root` using `privateDir` as the advertised team
792
+ * store. The per-worktree `.hunch/local.json` alone proves nothing: a repository can
793
+ * ship one that points at another checkout's store. Consent is an explicit trust
794
+ * entry, or the git-common-dir pointer that only this machine's own setup writes
795
+ * (a git clone never delivers the source's `.git`; an archive that ships its own `.git`
796
+ * already ships hooks that run on the next git command), naming the same store. */
797
+ export function teamWiringConsented(root, team, privateDir) {
798
+ if (isTeamStoreTrusted(root, team))
799
+ return true;
800
+ try {
801
+ const common = checkoutCommonDir(root);
802
+ if (!common)
803
+ return false;
804
+ const pointer = JSON.parse(readFileSync(join(common, "hunch", "local.json"), "utf8"));
805
+ return typeof pointer.privateDir === "string" && isAbsolute(pointer.privateDir)
806
+ && sameFilesystemEntry(pointer.privateDir, privateDir);
807
+ }
808
+ catch {
809
+ return false;
810
+ }
811
+ }
812
+ export function untrustedTeamStoreMessage(team) {
813
+ return `.hunch/team.json advertises a shared memory store (${team.shared_repo}) that you have not trusted on this machine. ` +
814
+ "Connecting would clone it, serve its records as memory, and push your captures to it. " +
815
+ "If this is your team's store, run `hunch shared --trust`; otherwise leave it untrusted.";
816
+ }
685
817
  /** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
686
818
  * clone it to the worktree-stable anchor, and register the gitignored local pointer +
687
819
  * the git-common-dir pointer (mode "shared", auto-commit on) so every consumer — CLI,
688
820
  * MCP server, hooks, all worktrees — resolves the same single source of truth.
689
- * No-op (null) when an overlay is already configured, there's no team.json, or the
690
- * clone fails (best-effort: never throws, never blocks startup). Returns the overlay
821
+ * No-op (null) when an overlay is already configured, there's no team.json, this user
822
+ * has not trusted its URL (see trustTeamStore), or the clone fails (best-effort: never throws, never blocks startup). Returns the overlay
691
823
  * hunch dir when wired. */
692
824
  export function ensureTeamOverlay(root) {
693
825
  try {
@@ -705,12 +837,18 @@ export function ensureTeamOverlay(root) {
705
837
  // an existing pointer as a total no-op would leave those teams permanently
706
838
  // vulnerable until they deleted and recloned their memory.
707
839
  const configuredRoot = join(configured, "..");
840
+ if (!teamWiringConsented(root, team, configured))
841
+ return null;
708
842
  if (!overlayMatchesTeamRemote(root, configuredRoot))
709
843
  return null;
710
844
  installMergeDriver(configuredRoot, resolveInvocation().shell);
711
845
  ensureGitignore(configuredRoot);
712
846
  return null; // already wired and alive
713
847
  }
848
+ // Everything below creates NEW wiring (a clone, or adopting a pre-existing
849
+ // directory), so it needs this user's explicit consent to the advertised URL.
850
+ if (!isTeamStoreTrusted(root, team))
851
+ return null;
714
852
  const anchor = mainWorktreeRoot(root);
715
853
  const dest = join(anchor, ".hunch-private");
716
854
  if (!existsSync(dest)) {
@@ -4,5 +4,6 @@
4
4
  * Carries the overlay MODE so every worktree routes captures identically (shared =
5
5
  * unified store, private = split). Returns true once the shared pointer is in place
6
6
  * (memory is worktree-shared), false when there's no overlay configured or no git
7
- * common dir. Reused by `init`/`worktree`/`private`/`shared`. */
8
- export declare function ensureSharedOverlayPointer(root: string, overlayDir: string | undefined, autoCommit: boolean, mode?: "private" | "shared"): boolean;
7
+ * common dir. Reused by `init`/`worktree`/`private`/`shared`, which pass
8
+ * `claimSeparateGitDirLayout` (see claimSeparateGitDir). */
9
+ export declare function ensureSharedOverlayPointer(root: string, overlayDir: string | undefined, autoCommit: boolean, mode?: "private" | "shared", claimSeparateGitDirLayout?: boolean): boolean;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, readFileSync, mkdirSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
- import { gitCommonDir } from "../extractors/git.js";
9
+ import { checkoutCommonDir, claimSeparateGitDir } from "../extractors/git.js";
10
10
  import { writeFileAtomic } from "../core/io.js";
11
11
  /** Register the resolved private overlay at the shared git common dir, so every worktree
12
12
  * of this repo auto-discovers the same memory. Idempotent (writes only when missing or
@@ -14,9 +14,12 @@ import { writeFileAtomic } from "../core/io.js";
14
14
  * Carries the overlay MODE so every worktree routes captures identically (shared =
15
15
  * unified store, private = split). Returns true once the shared pointer is in place
16
16
  * (memory is worktree-shared), false when there's no overlay configured or no git
17
- * common dir. Reused by `init`/`worktree`/`private`/`shared`. */
18
- export function ensureSharedOverlayPointer(root, overlayDir, autoCommit, mode = "private") {
19
- const common = overlayDir ? gitCommonDir(root) : "";
17
+ * common dir. Reused by `init`/`worktree`/`private`/`shared`, which pass
18
+ * `claimSeparateGitDirLayout` (see claimSeparateGitDir). */
19
+ export function ensureSharedOverlayPointer(root, overlayDir, autoCommit, mode = "private", claimSeparateGitDirLayout = false) {
20
+ // Only an explicit setup command the user ran in this checkout may claim a
21
+ // `git init --separate-git-dir` layout — never a store open or team auto-wiring.
22
+ const common = overlayDir ? checkoutCommonDir(root) || (claimSeparateGitDirLayout ? claimSeparateGitDir(root) : "") : "";
20
23
  if (!common || !overlayDir)
21
24
  return false;
22
25
  const file = join(common, "hunch", "local.json");