@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.
- package/README.md +1 -1
- package/dist/cli/index.js +402 -154
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/docanchors.js +2 -1
- package/dist/core/eol.d.ts +10 -0
- package/dist/core/eol.js +23 -0
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/groundingMerge.js +8 -6
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +19 -3
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/k8sManifest.js +15 -42
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/gitignore.js +2 -1
- package/dist/integrations/hooks.js +5 -4
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +34 -22
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- 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
|
|
47
|
-
* it
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
if (
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
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");
|