@davesheffer/hunch 1.8.2 → 1.9.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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -5,46 +5,130 @@
5
5
  * fresh clone / a new teammate / a headless agent can auto-wire without being told.
6
6
  * Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
7
7
  */
8
- import { existsSync, readFileSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
9
+ import { basename, dirname, join, resolve } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { writeFileAtomic } from "../core/io.js";
12
+ import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
12
13
  import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
13
- import { mainWorktreeRoot } from "../extractors/git.js";
14
+ import { canonicalRemoteUrl, gitNullDevice, mainWorktreeRoot, sameRemoteUrl } from "../extractors/git.js";
14
15
  import { HunchStore } from "../store/hunchStore.js";
15
16
  import { JsonStore } from "../store/jsonStore.js";
16
17
  import { ensureSharedOverlayPointer } from "./worktree.js";
18
+ import { installMergeDriver } from "./mergeDriver.js";
19
+ import { ensureGitignore } from "./gitignore.js";
20
+ import { resolveInvocation } from "../cli/invocation.js";
21
+ export const DEFAULT_TEAM_REF = "refs/heads/main";
22
+ export function safeTeamRef(value) {
23
+ const ref = value.trim();
24
+ if (!ref.startsWith("refs/heads/") || ref === "refs/heads/")
25
+ return null;
26
+ const checked = spawnSync("git", ["check-ref-format", ref], {
27
+ stdio: "ignore",
28
+ env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
29
+ });
30
+ return checked.status === 0 ? ref : null;
31
+ }
32
+ export function teamSharedRef(team) {
33
+ return team.shared_ref ?? DEFAULT_TEAM_REF;
34
+ }
17
35
  /** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
18
36
  * (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
19
37
  * it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
20
38
  * smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
21
- * merely opening a repo. Allow only https:// / ssh:// / git:// / scp-style git@host:path,
22
- * and never anything that could parse as a git flag. */
39
+ * merely opening a repo. Allow only credential-free https://, ssh://, git://,
40
+ * scp-style git@host:path, and never anything that could parse as a Git flag. */
23
41
  export function safeGitUrl(url) {
24
42
  const u = url.trim();
25
43
  if (!u || u.startsWith("-"))
26
44
  return null; // flag smuggling
27
- if (/^(https|ssh|git):\/\/[^\s]+$/i.test(u))
28
- return u;
29
- if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
30
- return u; // scp-like, excludes ext::
45
+ // Query strings and fragments are neither needed to locate a Git repository nor
46
+ // safe to publish. Reject them for every accepted form rather than trying to keep
47
+ // an inevitably incomplete list of token/password parameter names.
48
+ if (/[?#]/.test(u))
49
+ return null;
31
50
  // A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
32
51
  // local test remote. Safe: a local clone never executes hooks or remote helpers. The
33
52
  // file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
34
53
  if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
35
54
  return u;
36
- return null;
55
+ // SCP syntax carries an SSH account name, not an embedded authentication secret.
56
+ // Its deliberately narrow account/host grammar cannot encode a password delimiter.
57
+ if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
58
+ return u; // scp-like, excludes ext::
59
+ // WHATWG URL parsing intentionally repairs forms such as `https:host/path` and
60
+ // backslash-separated HTTPS URLs. Require the exact Git URL shape first so parsing
61
+ // validates an allowlisted form instead of silently broadening the allowlist.
62
+ if (!/^(?:https|ssh|git):\/\/[^\s]+$/i.test(u))
63
+ return null;
64
+ const authority = u.slice(u.indexOf("://") + 3).split("/", 1)[0] ?? "";
65
+ if (!authority || authority.includes("\\"))
66
+ return null;
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(u);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ if (!new Set(["https:", "ssh:", "git:"]).has(parsed.protocol) || !parsed.hostname)
75
+ return null;
76
+ const at = authority.lastIndexOf("@");
77
+ if (parsed.protocol === "ssh:") {
78
+ // A normal SSH username (`ssh://git@host/repo`) is routing, and remains valid.
79
+ // A colon in its userinfo is a password separator. Check both the raw and decoded
80
+ // spellings so percent-encoding cannot hide the delimiter from this committed gate.
81
+ if (at >= 0) {
82
+ const userinfo = authority.slice(0, at);
83
+ let decodedUserinfo;
84
+ try {
85
+ decodedUserinfo = decodeURIComponent(userinfo);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ if (!userinfo || userinfo.includes(":") || decodedUserinfo.includes(":") || decodedUserinfo.includes("@"))
91
+ return null;
92
+ }
93
+ if (parsed.password)
94
+ return null;
95
+ }
96
+ else if (at >= 0 || parsed.username || parsed.password) {
97
+ // HTTPS and unauthenticated git:// have no legitimate committed userinfo.
98
+ return null;
99
+ }
100
+ return u;
37
101
  }
38
102
  /** The committed team pointer, or null. Tolerant — an invalid file reads as absent, and
39
103
  * a URL that fails the safety gate reads as absent too (never propagated to a consumer). */
40
104
  export function readTeamConfig(root) {
41
105
  try {
42
- const file = join(hunchPaths(root).hunch, "team.json");
43
- if (!existsSync(file))
106
+ const lexicalRoot = resolve(root);
107
+ const rootStat = lstatSync(lexicalRoot);
108
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
109
+ return null;
110
+ const canonicalRoot = realpathSync(lexicalRoot);
111
+ const hunchDir = join(lexicalRoot, ".hunch");
112
+ const hunchStat = lstatSync(hunchDir);
113
+ if (hunchStat.isSymbolicLink() || !hunchStat.isDirectory()
114
+ || realpathSync(hunchDir) !== join(canonicalRoot, ".hunch"))
115
+ return null;
116
+ const file = join(hunchDir, "team.json");
117
+ const stat = lstatSync(file);
118
+ // team.json is committed attacker input read automatically at startup.
119
+ // Never follow a link/device/FIFO or ingest an unbounded blob merely by
120
+ // opening a repository.
121
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024
122
+ || realpathSync(file) !== join(canonicalRoot, ".hunch", "team.json"))
44
123
  return null;
45
124
  const v = JSON.parse(readFileSync(file, "utf8"));
46
125
  const url = typeof v.shared_repo === "string" ? safeGitUrl(v.shared_repo) : null;
47
- return url ? { shared_repo: url } : null;
126
+ if (!url)
127
+ return null;
128
+ if (v.shared_ref === undefined)
129
+ return { shared_repo: url };
130
+ const ref = typeof v.shared_ref === "string" ? safeTeamRef(v.shared_ref) : null;
131
+ return ref ? { shared_repo: url, shared_ref: ref } : null;
48
132
  }
49
133
  catch {
50
134
  return null;
@@ -52,7 +136,540 @@ export function readTeamConfig(root) {
52
136
  }
53
137
  /** Publish the team's shared-store URL (atomic; committed with the repo). */
54
138
  export function writeTeamConfig(root, cfg) {
55
- writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify(cfg, null, 2) + "\n");
139
+ const sharedRepo = safeGitUrl(cfg.shared_repo);
140
+ if (!sharedRepo)
141
+ throw new Error("refusing to write unsafe team repository URL; committed team URLs must be credential-free and cannot contain query or fragment data");
142
+ const sharedRef = cfg.shared_ref === undefined ? undefined : safeTeamRef(cfg.shared_ref);
143
+ if (cfg.shared_ref !== undefined && !sharedRef) {
144
+ throw new Error("refusing to write an unsafe team memory ref; it must be a valid refs/heads/* branch");
145
+ }
146
+ writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify({ shared_repo: sharedRepo, ...(sharedRef ? { shared_ref: sharedRef } : {}) }, null, 2) + "\n");
147
+ }
148
+ function localGitConfig(root) {
149
+ const result = spawnSync("git", ["-C", root, "config", "--local", "--includes", "--null", "--list"], {
150
+ encoding: "utf8",
151
+ stdio: ["ignore", "pipe", "ignore"],
152
+ env: boundedTeamGitEnv(),
153
+ });
154
+ if (result.status !== 0)
155
+ return null;
156
+ const config = new Map();
157
+ for (const entry of result.stdout.split("\0").filter(Boolean)) {
158
+ const newline = entry.indexOf("\n");
159
+ if (newline < 1)
160
+ return null;
161
+ const key = entry.slice(0, newline).toLowerCase();
162
+ const values = config.get(key) ?? [];
163
+ values.push(entry.slice(newline + 1));
164
+ config.set(key, values);
165
+ }
166
+ return config;
167
+ }
168
+ /** Git environment for every shared-route setup operation. Preserve ordinary
169
+ * credential configuration, but discard inherited repository/object selectors
170
+ * and executable transport/prompt/template overrides from the caller. */
171
+ export function boundedTeamGitEnv() {
172
+ const env = { ...process.env };
173
+ const localGitEnv = new Set([
174
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
175
+ "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
176
+ "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
177
+ "GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR", "GIT_NAMESPACE",
178
+ "GIT_QUARANTINE_PATH", "GIT_PROTOCOL", "GIT_EXEC_PATH", "GIT_TEMPLATE_DIR",
179
+ ]);
180
+ for (const key of Object.keys(env)) {
181
+ if (["GIT_SSH", "GIT_SSH_COMMAND", "GIT_PROXY_COMMAND", "GIT_ASKPASS", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE"].includes(key)
182
+ || localGitEnv.has(key)
183
+ || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
184
+ delete env[key];
185
+ }
186
+ env.GIT_CONFIG_NOSYSTEM = "1";
187
+ env.GIT_ALLOW_PROTOCOL = "https:ssh:git:file";
188
+ env.GIT_TERMINAL_PROMPT = "0";
189
+ return env;
190
+ }
191
+ /** Return false unless the effective non-system config can be inspected and
192
+ * contains no rewrite whose prefix applies to the exact contract URL.
193
+ * Credential helpers and other harmless global settings remain available; only
194
+ * destination-moving url.*.insteadOf/pushInsteadOf entries are excluded. */
195
+ function effectiveRouteUnrewritten(overlayRoot, fetchUrl, pushUrl) {
196
+ const result = spawnSync("git", ["-C", overlayRoot, "config", "--includes", "--show-scope", "--null", "--list"], {
197
+ encoding: "utf8",
198
+ stdio: ["ignore", "pipe", "ignore"],
199
+ env: boundedTeamGitEnv(),
200
+ });
201
+ if (result.status !== 0)
202
+ return false;
203
+ const fetchPrefixes = [];
204
+ const pushPrefixes = [];
205
+ const fields = result.stdout.split("\0").filter(Boolean);
206
+ if (fields.length % 2 !== 0)
207
+ return false;
208
+ for (let i = 1; i < fields.length; i += 2) {
209
+ const entry = fields[i];
210
+ const newline = entry.indexOf("\n");
211
+ if (newline < 1)
212
+ return false;
213
+ const key = entry.slice(0, newline).toLowerCase();
214
+ const value = entry.slice(newline + 1);
215
+ if (/^url\..*\.insteadof$/.test(key))
216
+ fetchPrefixes.push(value);
217
+ if (/^url\..*\.pushinsteadof$/.test(key))
218
+ pushPrefixes.push(value);
219
+ }
220
+ return !fetchPrefixes.some((prefix) => prefix && (fetchUrl.startsWith(prefix) || pushUrl.startsWith(prefix)))
221
+ && !pushPrefixes.some((prefix) => prefix && pushUrl.startsWith(prefix));
222
+ }
223
+ function overlayBranch(overlayRoot) {
224
+ try {
225
+ const head = readFileSync(join(overlayRoot, ".git", "HEAD"), "utf8").trim();
226
+ const match = head.match(/^ref: refs\/heads\/(.+)$/);
227
+ return match?.[1] ?? null;
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ }
233
+ function provenRouteAgainst(team, teamUrlCwd, overlayRoot) {
234
+ const config = localGitConfig(overlayRoot);
235
+ const branch = overlayBranch(overlayRoot);
236
+ if (!config || !branch)
237
+ return null;
238
+ const values = (key) => config.get(key.toLowerCase());
239
+ const fetchUrls = values("remote.origin.url");
240
+ const explicitPushUrls = values("remote.origin.pushurl");
241
+ const pushUrls = explicitPushUrls ?? fetchUrls;
242
+ if (fetchUrls?.length !== 1 || pushUrls?.length !== 1)
243
+ return null;
244
+ // The committed URL gate also governs the physical origin values. Identity
245
+ // equivalence alone would accept a credentialed/query-bearing GitHub URL or
246
+ // file:// spelling that normalizes to the advertised repository.
247
+ if (!safeGitUrl(fetchUrls[0]) || !safeGitUrl(pushUrls[0])
248
+ || !sameRemoteUrl(fetchUrls[0], overlayRoot, team.shared_repo, teamUrlCwd)
249
+ || !sameRemoteUrl(pushUrls[0], overlayRoot, team.shared_repo, teamUrlCwd)
250
+ || !effectiveRouteUnrewritten(overlayRoot, fetchUrls[0], pushUrls[0]))
251
+ return null;
252
+ const forbiddenKeys = [
253
+ "remote.origin.push",
254
+ "remote.origin.uploadpack",
255
+ "remote.origin.receivepack",
256
+ "remote.origin.mirror",
257
+ "remote.origin.proxy",
258
+ "core.sshCommand",
259
+ "core.gitProxy",
260
+ "push.default",
261
+ ];
262
+ if (forbiddenKeys.some((key) => values(key)?.length))
263
+ return null;
264
+ if ([...config.keys()].some((key) => /^url\..*\.(insteadof|pushinsteadof)$/.test(key)))
265
+ return null;
266
+ const fetchRefspecs = values("remote.origin.fetch");
267
+ if (fetchRefspecs?.length !== 1 || fetchRefspecs[0] !== "+refs/heads/*:refs/remotes/origin/*")
268
+ return null;
269
+ const branchRemote = values(`branch.${branch}.remote`);
270
+ const branchMerge = values(`branch.${branch}.merge`);
271
+ let sharedRef = team.shared_ref ? safeTeamRef(team.shared_ref) : null;
272
+ if (branchRemote || branchMerge) {
273
+ if (branchRemote?.length !== 1 || branchRemote[0] !== "origin"
274
+ || branchMerge?.length !== 1)
275
+ return null;
276
+ const configuredRef = safeTeamRef(branchMerge[0]);
277
+ if (!configuredRef || (sharedRef && configuredRef !== sharedRef))
278
+ return null;
279
+ sharedRef = configuredRef;
280
+ }
281
+ const refs = spawnSync("git", ["-C", overlayRoot, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], {
282
+ encoding: "utf8",
283
+ stdio: ["ignore", "pipe", "ignore"],
284
+ env: boundedTeamGitEnv(),
285
+ });
286
+ if (refs.status !== 0)
287
+ return null;
288
+ const branches = refs.stdout.split(/\r?\n/)
289
+ .map((line) => line.trim())
290
+ .filter((line) => line && line !== "refs/remotes/origin/HEAD");
291
+ if (branches.length > 1)
292
+ return null;
293
+ const trackedRef = branches.length === 1
294
+ ? safeTeamRef(branches[0].replace(/^refs\/remotes\/origin\//, "refs/heads/"))
295
+ : null;
296
+ if (branches.length === 1 && (!trackedRef || (sharedRef && trackedRef !== sharedRef)))
297
+ return null;
298
+ if (!sharedRef) {
299
+ // A pre-shared_ref team file followed the overlay's real canonical branch.
300
+ // Preserve that behavior only when it is unambiguous: one tracked origin
301
+ // branch, or the unborn/current branch for a genuinely empty remote.
302
+ sharedRef = trackedRef ?? safeTeamRef(`refs/heads/${branch}`);
303
+ }
304
+ if (!sharedRef)
305
+ return null;
306
+ for (const key of [`branch.${branch}.pushRemote`, "remote.pushDefault"]) {
307
+ const selected = values(key);
308
+ if (selected && (selected.length !== 1 || selected[0] !== "origin"))
309
+ return null;
310
+ }
311
+ return { fetchUrl: fetchUrls[0], pushUrl: pushUrls[0], sharedRef };
312
+ }
313
+ function provenRoute(root, overlayRoot) {
314
+ const team = readTeamConfig(root);
315
+ if (!team)
316
+ return null;
317
+ const route = provenRouteAgainst(team, root, overlayRoot);
318
+ return route ? { team, ...route } : null;
319
+ }
320
+ /** Persist the graph epoch in clone-local Git metadata. If team.json and origin
321
+ * are coherently repointed after a write was admitted, the old checkout must not
322
+ * be reusable as the new graph on reconnect: it may contain a refused local
323
+ * record/commit. A missing binding is a one-time legacy migration after full
324
+ * route proof; an invalid or different binding is never overwritten. */
325
+ function routeBoundToClone(root, overlayRoot, route) {
326
+ const file = join(overlayRoot, ".git", "hunch-team-route.json");
327
+ const expectedRepo = canonicalRemoteUrl(route.team.shared_repo, root);
328
+ if (!safeGitUrl(expectedRepo))
329
+ return false;
330
+ if (!existsSync(file)) {
331
+ try {
332
+ writeFileAtomic(file, `${JSON.stringify({
333
+ version: 1,
334
+ shared_repo: expectedRepo,
335
+ shared_ref: route.sharedRef,
336
+ }, null, 2)}\n`);
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
342
+ try {
343
+ const value = JSON.parse(readFileSync(file, "utf8"));
344
+ return value.version === 1
345
+ && typeof value.shared_repo === "string"
346
+ && !!safeGitUrl(value.shared_repo)
347
+ && sameRemoteUrl(value.shared_repo, overlayRoot, expectedRepo, overlayRoot)
348
+ && value.shared_ref === route.sharedRef;
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
354
+ function boundProvenRoute(root, overlayRoot) {
355
+ const route = provenRoute(root, overlayRoot);
356
+ return route && routeBoundToClone(root, overlayRoot, route) ? route : null;
357
+ }
358
+ /** Prove that the physical overlay reads from and writes to the repository
359
+ * advertised by the committed team config. Path/mode checks alone are not
360
+ * enough: an old healthy clone can otherwise report "current" against a stale
361
+ * origin after team.json changes, silently splitting the team graph.
362
+ *
363
+ * Require one exact fetch URL and one exact push URL for origin, plus an origin
364
+ * upstream/push selector when those branch-level overrides exist. Git's
365
+ * Applicable local or global URL rewrite rules are rejected, so the exact URLs
366
+ * captured from local config remain the URLs handed to Git at the network seam. */
367
+ export function overlayMatchesTeamRemote(root, overlayRoot) {
368
+ return safeOverlayTree(overlayRoot) && !!boundProvenRoute(root, overlayRoot);
369
+ }
370
+ /** Snapshot the effective URLs after proving the committed pointer, local
371
+ * transport configuration, and canonical ref all agree. Sync commands receive
372
+ * this object and re-run `verify` immediately around every network operation. */
373
+ export function teamRemoteContract(root, overlayRoot) {
374
+ if (!safeOverlayTree(overlayRoot))
375
+ return null;
376
+ const route = boundProvenRoute(root, overlayRoot);
377
+ if (!route)
378
+ return null;
379
+ const fetchUrl = canonicalRemoteUrl(route.fetchUrl, overlayRoot);
380
+ const pushUrl = canonicalRemoteUrl(route.pushUrl, overlayRoot);
381
+ const ref = route.sharedRef;
382
+ return {
383
+ // Resolve local relative spellings in the overlay-root context once. The
384
+ // network commands run with `.hunch` as cwd, where handing Git the raw
385
+ // relative value would otherwise name a different repository.
386
+ fetchUrl,
387
+ pushUrl,
388
+ urlCwd: overlayRoot,
389
+ ref,
390
+ // Filesystem/tree safety is proved independently immediately before any
391
+ // materialization. The route check stays deliberately lightweight because
392
+ // it runs around each network seam in long-lived MCP traffic.
393
+ verify: () => {
394
+ const current = boundProvenRoute(root, overlayRoot);
395
+ return !!current && current.sharedRef === ref
396
+ && sameRemoteUrl(canonicalRemoteUrl(current.fetchUrl, overlayRoot), overlayRoot, fetchUrl, overlayRoot)
397
+ && sameRemoteUrl(canonicalRemoteUrl(current.pushUrl, overlayRoot), overlayRoot, pushUrl, overlayRoot);
398
+ },
399
+ };
400
+ }
401
+ /** Setup-time form of the same contract, used before team.json is published.
402
+ * It proves the existing overlay's physical origin/config against the explicit
403
+ * command arguments so attach/refresh cannot traverse an ambient refspec or
404
+ * transport override during the setup command itself. */
405
+ export function explicitTeamRemoteContract(overlayRoot, sharedRepo, sharedRepoCwd, sharedRef) {
406
+ const repo = safeGitUrl(sharedRepo);
407
+ const ref = safeTeamRef(sharedRef);
408
+ if (!repo || !ref || !safeOverlayTree(overlayRoot))
409
+ return null;
410
+ const team = { shared_repo: repo, shared_ref: ref };
411
+ const route = provenRouteAgainst(team, sharedRepoCwd, overlayRoot);
412
+ if (!route)
413
+ return null;
414
+ return {
415
+ fetchUrl: canonicalRemoteUrl(route.fetchUrl, overlayRoot),
416
+ pushUrl: canonicalRemoteUrl(route.pushUrl, overlayRoot),
417
+ urlCwd: overlayRoot,
418
+ ref,
419
+ verify: () => !!provenRouteAgainst(team, sharedRepoCwd, overlayRoot),
420
+ };
421
+ }
422
+ /** Undefined means this checkout does not advertise team routing. Once the
423
+ * committed file exists, failure to prove it returns an always-refusing
424
+ * contract so a late config change can strand a local commit but can never fall
425
+ * through to ambient `git push`. */
426
+ export function advertisedTeamRemoteContract(root, overlayRoot) {
427
+ // An explicit per-process overlay outranks committed team discovery. Returning
428
+ // no advertised contract lets that selected overlay use its own configured
429
+ // route instead of being locally committed and then permanently stranded by
430
+ // an unrelated team.json destination.
431
+ if (process.env.HUNCH_PRIVATE_DIR?.trim())
432
+ return undefined;
433
+ if (!existsSync(join(hunchPaths(root).hunch, "team.json")))
434
+ return undefined;
435
+ return teamRemoteContract(root, overlayRoot) ?? {
436
+ fetchUrl: "",
437
+ pushUrl: "",
438
+ urlCwd: root,
439
+ ref: "",
440
+ verify: () => false,
441
+ };
442
+ }
443
+ function checkoutIsolatedEnv() {
444
+ return {
445
+ ...boundedTeamGitEnv(),
446
+ // The fetch has already completed. Materialization needs no credentials or
447
+ // user customizations, so suppress every ambient filter/attributes source.
448
+ // Git for Windows does not accept Node's native `\\.\nul` spelling as a
449
+ // config pathname; its DOS device name is stable from every working dir.
450
+ GIT_CONFIG_GLOBAL: gitNullDevice(),
451
+ GIT_ATTR_NOSYSTEM: "1",
452
+ };
453
+ }
454
+ function exactCommit(root, revision, env) {
455
+ const result = spawnSync("git", ["-C", root, "rev-parse", "--verify", `${revision}^{commit}`], {
456
+ encoding: "utf8",
457
+ stdio: ["ignore", "pipe", "ignore"],
458
+ env,
459
+ timeout: 2_000,
460
+ });
461
+ const oid = result.status === 0 ? result.stdout.trim() : "";
462
+ return {
463
+ oid: /^[0-9a-f]{40,64}$/i.test(oid) ? oid : null,
464
+ process: result,
465
+ };
466
+ }
467
+ function exactTreeListing(root, oid, env) {
468
+ const result = spawnSync("git", ["-C", root, "ls-tree", "--full-tree", "-r", "-t", "-z", oid], {
469
+ encoding: "utf8",
470
+ stdio: ["ignore", "pipe", "ignore"],
471
+ env,
472
+ timeout: 2_000,
473
+ maxBuffer: 64 * 1024 * 1024,
474
+ });
475
+ return result.status === 0 && safeOverlayGitTreeListing(result.stdout) ? result.stdout : null;
476
+ }
477
+ function repositoryHasNoRefs(root, env) {
478
+ const result = spawnSync("git", ["-C", root, "for-each-ref", "--format=%(refname)"], {
479
+ encoding: "utf8",
480
+ stdio: ["ignore", "pipe", "ignore"],
481
+ env,
482
+ timeout: 2_000,
483
+ });
484
+ return result.status === 0 && result.stdout.trim() === "";
485
+ }
486
+ function treeAttributesAreSafe(root, listing, env) {
487
+ return hunchTreeAttributesAreSafe(listing, (oid) => {
488
+ const blob = spawnSync("git", ["-C", root, "cat-file", "blob", oid], {
489
+ encoding: "utf8",
490
+ stdio: ["ignore", "pipe", "ignore"],
491
+ env,
492
+ timeout: 2_000,
493
+ maxBuffer: 4 * 1024 * 1024,
494
+ });
495
+ return blob.status === 0 ? blob.stdout : null;
496
+ });
497
+ }
498
+ /** Opt-in, secret-free diagnostics for a transaction that otherwise fails
499
+ * closed as `null`. Never print the remote, path, stderr, or exception text: a
500
+ * committed team URL may still identify private infrastructure. */
501
+ function reportTeamCloneFailure(stage, result, thrown) {
502
+ if (process.env.HUNCH_TEAM_CLONE_DEBUG !== "1")
503
+ return;
504
+ const rawCode = result?.error?.code
505
+ ?? thrown?.code;
506
+ const code = typeof rawCode === "string" && /^[A-Z0-9_]+$/.test(rawCode)
507
+ ? ` code=${rawCode}`
508
+ : "";
509
+ const status = typeof result?.status === "number" ? ` status=${result.status}` : "";
510
+ process.stderr.write(`[hunch-team-clone] stage=${stage}${status}${code}\n`);
511
+ }
512
+ /** Materialize only one already-fetched, immutable commit. The clone has no
513
+ * worktree yet, so unsafe tree modes or attributes are rejected before any
514
+ * remote-controlled path can invoke a hook/filter or reach disk. */
515
+ function materializeValidatedClone(team, teamRoot, overlayRoot, emptyHooks) {
516
+ const route = provenRouteAgainst(team, teamRoot, overlayRoot);
517
+ if (!route) {
518
+ reportTeamCloneFailure("materialize-route");
519
+ return null;
520
+ }
521
+ const sharedRef = route.sharedRef;
522
+ const branch = sharedRef.slice("refs/heads/".length);
523
+ if (!branch || overlayBranch(overlayRoot) !== branch) {
524
+ reportTeamCloneFailure("materialize-branch");
525
+ return null;
526
+ }
527
+ const env = checkoutIsolatedEnv();
528
+ const originProbe = exactCommit(overlayRoot, `refs/remotes/origin/${branch}`, env);
529
+ const headProbe = exactCommit(overlayRoot, "HEAD", env);
530
+ const oid = originProbe.oid;
531
+ const head = headProbe.oid;
532
+ // A genuinely empty remote has no object to validate or materialize. Retain
533
+ // its metadata-only clone so a later sole canonical branch can be joined;
534
+ // no remote-controlled working-tree path exists at this point.
535
+ if (!oid || !head) {
536
+ const empty = !oid && !head && repositoryHasNoRefs(overlayRoot, env) && safeOverlayTree(overlayRoot);
537
+ if (!empty) {
538
+ if (!oid)
539
+ reportTeamCloneFailure("materialize-origin-object", originProbe.process);
540
+ if (!head)
541
+ reportTeamCloneFailure("materialize-head-object", headProbe.process);
542
+ if (!oid && !head)
543
+ reportTeamCloneFailure("materialize-empty-proof");
544
+ }
545
+ return empty ? { sharedRef, empty: true } : null;
546
+ }
547
+ if (head !== oid) {
548
+ reportTeamCloneFailure("materialize-object-mismatch");
549
+ return null;
550
+ }
551
+ const listing = exactTreeListing(overlayRoot, oid, env);
552
+ if (!listing || !treeAttributesAreSafe(overlayRoot, listing, env)) {
553
+ reportTeamCloneFailure("materialize-tree");
554
+ return null;
555
+ }
556
+ const reset = spawnSync("git", [
557
+ "-C", overlayRoot,
558
+ "-c", `core.hooksPath=${emptyHooks}`,
559
+ "-c", `core.attributesFile=${gitNullDevice()}`,
560
+ "reset", "--hard", oid,
561
+ ], {
562
+ stdio: "ignore",
563
+ env,
564
+ timeout: 5_000,
565
+ });
566
+ if (reset.status !== 0) {
567
+ reportTeamCloneFailure("materialize-reset", reset);
568
+ return null;
569
+ }
570
+ // Re-prove the immutable object identity and the materialized filesystem.
571
+ // No binding/pointer is written until all three views agree.
572
+ const afterHeadProbe = exactCommit(overlayRoot, "HEAD", env);
573
+ const afterHead = afterHeadProbe.oid;
574
+ const afterListing = afterHead === oid ? exactTreeListing(overlayRoot, oid, env) : null;
575
+ const safe = afterHead === oid
576
+ && !!afterListing
577
+ && treeAttributesAreSafe(overlayRoot, afterListing, env)
578
+ && safeOverlayTree(overlayRoot);
579
+ if (!safe) {
580
+ if (!afterHead)
581
+ reportTeamCloneFailure("materialize-post-reset-head", afterHeadProbe.process);
582
+ reportTeamCloneFailure("materialize-post-reset");
583
+ }
584
+ return safe ? { sharedRef, empty: false } : null;
585
+ }
586
+ /** Clone a shared memory repository without checking out attacker-controlled
587
+ * paths, validate its exact route/OID/tree/attributes, and only then publish the
588
+ * fully materialized clone at `destination`. Failure removes both quarantine and
589
+ * destination so callers cannot accidentally wire a partially validated graph. */
590
+ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination, opts = {}) {
591
+ const repo = safeGitUrl(sharedRepo);
592
+ const sharedRef = opts.sharedRef === undefined ? undefined : safeTeamRef(opts.sharedRef);
593
+ if (!repo || (opts.sharedRef !== undefined && !sharedRef) || existsSync(destination)) {
594
+ reportTeamCloneFailure("preflight");
595
+ return null;
596
+ }
597
+ const requestedTimeout = opts.timeoutMs ?? 5_000;
598
+ const timeoutMs = Number.isFinite(requestedTimeout)
599
+ ? Math.min(30_000, Math.max(1, Math.trunc(requestedTimeout)))
600
+ : 5_000;
601
+ const parent = dirname(destination);
602
+ const prefix = basename(destination);
603
+ let stagedDest = "";
604
+ let guardRoot = "";
605
+ let installed = false;
606
+ let accepted = false;
607
+ let stage = "quarantine";
608
+ try {
609
+ stagedDest = mkdtempSync(join(parent, `${prefix}.tmp-`));
610
+ guardRoot = mkdtempSync(join(parent, `${prefix}.guard-`));
611
+ const emptyHooks = join(guardRoot, "hooks");
612
+ const emptyTemplate = join(guardRoot, "template");
613
+ mkdirSync(emptyHooks);
614
+ mkdirSync(emptyTemplate);
615
+ stage = "route-rewrite";
616
+ if (!effectiveRouteUnrewritten(stagedDest, repo, repo)) {
617
+ reportTeamCloneFailure(stage);
618
+ return null;
619
+ }
620
+ const cloneEnv = boundedTeamGitEnv();
621
+ stage = "clone";
622
+ const cloned = spawnSync("git", [
623
+ "-c", "protocol.ext.allow=never",
624
+ "-c", `core.hooksPath=${emptyHooks}`,
625
+ "clone", "--no-checkout", `--template=${emptyTemplate}`,
626
+ "--", repo, stagedDest,
627
+ ], {
628
+ stdio: "ignore",
629
+ env: cloneEnv,
630
+ timeout: timeoutMs,
631
+ });
632
+ if (cloned.status !== 0) {
633
+ reportTeamCloneFailure(stage, cloned);
634
+ return null;
635
+ }
636
+ const validated = materializeValidatedClone({ shared_repo: repo, ...(sharedRef ? { shared_ref: sharedRef } : {}) }, sharedRepoCwd, stagedDest, emptyHooks);
637
+ if (!validated)
638
+ return null;
639
+ stage = "pre-publish-contract";
640
+ if (!explicitTeamRemoteContract(stagedDest, repo, sharedRepoCwd, validated.sharedRef)) {
641
+ reportTeamCloneFailure(stage);
642
+ return null;
643
+ }
644
+ stage = "pre-publish-race";
645
+ if (existsSync(destination)) {
646
+ reportTeamCloneFailure(stage);
647
+ return null;
648
+ }
649
+ stage = "publish-rename";
650
+ renameSync(stagedDest, destination);
651
+ stagedDest = "";
652
+ installed = true;
653
+ stage = "post-publish-contract";
654
+ if (!explicitTeamRemoteContract(destination, repo, sharedRepoCwd, validated.sharedRef)) {
655
+ reportTeamCloneFailure(stage);
656
+ return null;
657
+ }
658
+ accepted = true;
659
+ return validated;
660
+ }
661
+ catch (error) {
662
+ reportTeamCloneFailure(stage ?? "unexpected", undefined, error);
663
+ return null;
664
+ }
665
+ finally {
666
+ if (guardRoot)
667
+ rmSync(guardRoot, { recursive: true, force: true });
668
+ if (stagedDest)
669
+ rmSync(stagedDest, { recursive: true, force: true });
670
+ if (installed && !accepted)
671
+ rmSync(destination, { recursive: true, force: true });
672
+ }
56
673
  }
57
674
  /** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
58
675
  * clone it to the worktree-stable anchor, and register the gitignored local pointer +
@@ -71,23 +688,49 @@ export function ensureTeamOverlay(root) {
71
688
  const probe = new HunchStore(hunchPaths(root));
72
689
  const configured = probe.privateDir;
73
690
  probe.close();
74
- if (configured && existsSync(configured))
691
+ if (configured && existsSync(configured)) {
692
+ // Upgrade/repair clone-local capabilities on every startup. Older shared
693
+ // overlays predate the merge-driver/runtime-ignore installation; treating
694
+ // an existing pointer as a total no-op would leave those teams permanently
695
+ // vulnerable until they deleted and recloned their memory.
696
+ const configuredRoot = join(configured, "..");
697
+ if (!overlayMatchesTeamRemote(root, configuredRoot))
698
+ return null;
699
+ installMergeDriver(configuredRoot, resolveInvocation().shell);
700
+ ensureGitignore(configuredRoot);
75
701
  return null; // already wired and alive
702
+ }
76
703
  const anchor = mainWorktreeRoot(root);
77
704
  const dest = join(anchor, ".hunch-private");
78
705
  if (!existsSync(dest)) {
79
- // Defense in depth on top of safeGitUrl: `--` stops flag parsing, the protocol
80
- // allowlist + ext:: kill-switch block command-running transports, and no terminal
81
- // prompt means a private remote can't hang a headless MCP/agent start.
82
- const r = spawnSync("git", ["-c", "protocol.ext.allow=never", "clone", "--", team.shared_repo, dest], {
83
- stdio: "ignore",
84
- env: { ...process.env, GIT_ALLOW_PROTOCOL: "https:ssh:git:file", GIT_TERMINAL_PROMPT: "0" },
706
+ const cloned = cloneValidatedTeamOverlay(team.shared_repo, root, dest, {
707
+ sharedRef: team.shared_ref,
708
+ timeoutMs: 5_000,
85
709
  });
86
- if (r.status !== 0)
87
- return null; // offline / no access — stay unwired, never crash
710
+ if (!cloned || !overlayMatchesTeamRemote(root, dest)) {
711
+ if (cloned)
712
+ rmSync(dest, { recursive: true, force: true });
713
+ return null; // offline / invalid / no access — stay unwired, never crash
714
+ }
88
715
  }
716
+ // This MUST precede ensureDirs: `.hunch` itself, any Hunch kind directory/file,
717
+ // `.gitignore`, or `.gitattributes` can be a tracked symlink in the remote.
718
+ // Following even one would let merely opening a project create or overwrite
719
+ // files outside the auto-cloned overlay.
720
+ if (!overlayMatchesTeamRemote(root, dest))
721
+ return null;
89
722
  const hunchDir = join(dest, ".hunch");
90
723
  new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
724
+ // `.gitattributes` and the merge driver command are clone-local capabilities:
725
+ // overlay auto-commits deliberately stage only `.hunch/**/*.json`, so the creator's
726
+ // driver configuration never rides the memory remote. Install it for every freshly
727
+ // discovered teammate/agent clone (idempotently) or same-record conflicts would keep
728
+ // aborting forever on machines that did not run `hunch shared` themselves.
729
+ installMergeDriver(dest, resolveInvocation().shell);
730
+ // A real Git conflict runs the Hunch CLI with this overlay as cwd, which can
731
+ // create a derived local SQLite index alongside the shared JSON. Ignore that
732
+ // rebuildable state so it can never poison the JSON-only publication guard.
733
+ ensureGitignore(dest);
91
734
  // Merge into any existing local.json (con_8460b6770f): a per-machine autoCommit
92
735
  // opt-out must survive the auto-wiring; an unparseable file is left alone.
93
736
  const localFile = join(hunchPaths(root).hunch, "local.json");