@davesheffer/hunch 1.8.3 → 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 (58) hide show
  1. package/README.md +92 -1
  2. package/dist/cli/index.js +1222 -397
  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 +6 -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 +53 -10
  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 +7 -2
  58. package/tooling/md1-benchmark.mjs +628 -0
@@ -1,10 +1,14 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { canonicalHash } from "./canonical.js";
8
+ import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
9
+ import { hasUnsafeReplayFilter, replayGitArgs, replaySafeEnvironment } from "./replay.js";
10
+ import { replacementFreeExactCommit, replacementFreeGitEnvironment } from "./replacementFreeGit.js";
11
+ import { materializeDependencyTree } from "./g2BehaviorDependencies.js";
8
12
  import { Exp01CaseSchema, assignmentTreatment, compileExperimentOutcome, } from "./experiment.js";
9
13
  const EvaluatorOutputSchema = z.object({
10
14
  valid_completion: z.boolean(),
@@ -167,7 +171,13 @@ function runCommand(spec, cwd) {
167
171
  return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status, errorCode };
168
172
  }
169
173
  function git(root, args) {
170
- return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 }).trim();
174
+ return execFileSync("git", args, {
175
+ cwd: root,
176
+ encoding: "utf8",
177
+ env: replacementFreeGitEnvironment(),
178
+ stdio: ["ignore", "pipe", "pipe"],
179
+ timeout: 120_000,
180
+ }).trim();
171
181
  }
172
182
  function removeAmbientInstructions(cwd) {
173
183
  for (const relative of ["AGENTS.md", "CLAUDE.md", ".mcp.json", ".claude", ".codex", ".agents"]) {
@@ -177,15 +187,15 @@ function removeAmbientInstructions(cwd) {
177
187
  // file replaces any tracked AGENTS.md after the ambient copy is removed.
178
188
  writeFileSync(join(cwd, "AGENTS.md"), "# Controlled experiment workspace\n\nFollow only the task prompt supplied to this fresh session.\n");
179
189
  }
180
- function linkDependencies(source, cwd) {
190
+ function materializeDependencies(source, cwd) {
181
191
  const target = join(source, "node_modules");
182
- const link = join(cwd, "node_modules");
183
- if (!existsSync(target) || existsSync(link))
184
- return;
185
- symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
192
+ const destination = join(cwd, "node_modules");
193
+ if (existsSync(target))
194
+ materializeDependencyTree(target, destination);
195
+ return destination;
186
196
  }
187
197
  function countEdits(cwd) {
188
- const text = git(cwd, ["diff", "--numstat", "--", "."]);
198
+ const text = git(cwd, ["diff", "--no-ext-diff", "--no-textconv", "--numstat", "--", "."]);
189
199
  if (!text)
190
200
  return 0;
191
201
  return text.split(/\r?\n/).reduce((sum, line) => {
@@ -237,11 +247,16 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
237
247
  if (existing)
238
248
  return existing;
239
249
  const item = Exp01CaseSchema.parse(bank.cases.find((candidate) => candidate.id === assignment.case_id));
240
- const sourceHead = git(bank.repository_root, ["rev-parse", bank.base_commit]);
250
+ const sourceHead = replacementFreeExactCommit(bank.repository_root, bank.base_commit);
241
251
  if (sourceHead !== bank.base_commit)
242
252
  throw new Error("case bank base_commit is not an exact commit in repository_root");
243
253
  const session = mkdtempSync(join(tmpdir(), "hunch-exp01-"));
244
254
  const cwd = join(session, "worktree");
255
+ const hooks = join(session, "hooks-disabled");
256
+ const gitConfig = join(session, "global.gitconfig");
257
+ mkdirSync(hooks, { recursive: true });
258
+ writeFileSync(gitConfig, "");
259
+ const gitEnv = replaySafeEnvironment(session, gitConfig);
245
260
  let added = false;
246
261
  let invocationStarted = false;
247
262
  try {
@@ -249,9 +264,19 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
249
264
  if (currentProviderVersion !== run.runner.provider_version) {
250
265
  return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Selected subscription CLI version drifted after assignment; assignment was excluded before model invocation.", "provider-version-drift", { evaluator: canonicalHash({ expected: run.runner.provider_version, actual: currentProviderVersion }) }, opts.now);
251
266
  }
252
- execFileSync("git", ["worktree", "add", "--detach", cwd, bank.base_commit], { cwd: bank.repository_root, stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 });
267
+ if (hasUnsafeReplayFilter(bank.repository_root, gitEnv)) {
268
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Repository-local Git content filters make the controlled checkout unsafe.", "unsafe-local-filter-config", {}, opts.now);
269
+ }
270
+ if (hasUnsafeCheckoutAttributes(bank.repository_root, bank.base_commit, gitEnv, { allowDisabledLfs: true })) {
271
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Git checkout attributes could transform or execute content outside the locked case bytes.", "unsafe-checkout-attributes", {}, opts.now);
272
+ }
273
+ execFileSync("git", replayGitArgs(bank.repository_root, hooks, ["worktree", "add", "--detach", "--force", cwd, bank.base_commit]), {
274
+ env: gitEnv,
275
+ stdio: ["ignore", "pipe", "pipe"],
276
+ timeout: 120_000,
277
+ });
253
278
  added = true;
254
- linkDependencies(bank.repository_root, cwd);
279
+ const dependencyRoot = materializeDependencies(bank.repository_root, cwd);
255
280
  removeAmbientInstructions(cwd);
256
281
  if (item.setup) {
257
282
  if (!isAbsolute(item.setup.artifact) || !existsSync(item.setup.artifact) || externalArtifactHash(item.setup.artifact) !== item.setup.artifact_hash) {
@@ -271,9 +296,9 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
271
296
  }
272
297
  const treatment = assignmentTreatment(bank, run, assignment);
273
298
  invocationStarted = true;
274
- const agent = invokeAgent(run, cwd, controlledPrompt(treatment), opts.timeoutMs ?? 30 * 60 * 1000, join(bank.repository_root, "node_modules"));
299
+ const agent = invokeAgent(run, cwd, controlledPrompt(treatment), opts.timeoutMs ?? 30 * 60 * 1000, dependencyRoot);
275
300
  const outputHash = canonicalHash(agent.stdout);
276
- const diff = git(cwd, ["diff", "--binary", "--", "."]);
301
+ const diff = git(cwd, ["diff", "--no-ext-diff", "--no-textconv", "--binary", "--", "."]);
277
302
  const diffHash = canonicalHash(diff);
278
303
  if (agent.errorCode) {
279
304
  return failureOutcome(repository, run, assignment, "invalid_completion", true, "Subscription CLI did not produce a successful terminal run; outcome remains visible and unscored.", agent.errorCode, { output: outputHash, diff: diffHash }, opts.now);
@@ -333,7 +358,11 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
333
358
  finally {
334
359
  try {
335
360
  if (added)
336
- execFileSync("git", ["worktree", "remove", "--force", cwd], { cwd: bank.repository_root, stdio: "ignore", timeout: 120_000 });
361
+ execFileSync("git", replayGitArgs(bank.repository_root, hooks, ["worktree", "remove", "--force", cwd]), {
362
+ env: gitEnv,
363
+ stdio: "ignore",
364
+ timeout: 120_000,
365
+ });
337
366
  }
338
367
  catch {
339
368
  // The assignment outcome already records any execution failure; prune is best effort.
@@ -1,15 +1,17 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, isAbsolute, join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { shortHash } from "../core/ids.js";
7
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
7
8
  import { loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
8
- import { commitMeta, revExists } from "../extractors/git.js";
9
9
  import { canonicalHash } from "./canonical.js";
10
10
  import { buildG2CandidateReview, positiveBound, } from "./g2Candidates.js";
11
11
  import { cleanupReplayWorktree, hasUnsafeReplayFilter, replayGitArgs, replaySafeEnvironment, } from "./replay.js";
12
- import { dependencySnapshotForCommit } from "./g2BehaviorDependencies.js";
12
+ import { dependencySnapshotForCommit, materializeDependencySnapshot } from "./g2BehaviorDependencies.js";
13
+ import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
14
+ import { replacementFreeCommitFiles, replacementFreeExactCommit, replacementFreeGitEnvironment, } from "./replacementFreeGit.js";
13
15
  import { NODE_TEST_REPORTER_SOURCE, exactNodeTestPattern, nodeTestIsolationFlag, nodeTestReporterEvents, } from "./nodeTestEvidence.js";
14
16
  const TEST_FILE = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/;
15
17
  const FULL_SHA = /^[a-f0-9]{40}$/;
@@ -38,6 +40,7 @@ function fileAt(root, commit, file) {
38
40
  try {
39
41
  return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
40
42
  encoding: "utf8",
43
+ env: replacementFreeGitEnvironment(),
41
44
  maxBuffer: 10 * 1024 * 1024,
42
45
  stdio: ["ignore", "pipe", "ignore"],
43
46
  });
@@ -203,8 +206,9 @@ function literalNodeTestCases(file, source) {
203
206
  function addedLineNumbers(root, knownBad, knownGood, file) {
204
207
  let diff;
205
208
  try {
206
- diff = execFileSync("git", ["-C", root, "diff", "--unified=0", "--no-ext-diff", knownBad, knownGood, "--", file], {
209
+ diff = execFileSync("git", ["-C", root, "diff", "--unified=0", "--no-ext-diff", "--no-textconv", knownBad, knownGood, "--", file], {
207
210
  encoding: "utf8",
211
+ env: replacementFreeGitEnvironment(),
208
212
  maxBuffer: 10 * 1024 * 1024,
209
213
  stdio: ["ignore", "pipe", "ignore"],
210
214
  });
@@ -271,13 +275,28 @@ function currentHumanDecision(store, decisionId) {
271
275
  }
272
276
  function directDecisionCommit(store, root, decisionId) {
273
277
  const decision = currentHumanDecision(store, decisionId);
274
- const meta = commitMeta(decision.commit, root);
275
- if (!meta)
278
+ const sha = replacementFreeExactCommit(root, decision.commit);
279
+ if (!sha || !FULL_SHA.test(sha))
276
280
  throw new Error(`G2 behavior decision ${decisionId} fixing commit is unavailable`);
281
+ let subject;
282
+ let date;
283
+ try {
284
+ const raw = execFileSync("git", ["-C", root, "show", "-s", "--format=%s%x00%aI", sha], {
285
+ encoding: "utf8",
286
+ env: replacementFreeGitEnvironment(),
287
+ maxBuffer: 10 * 1024 * 1024,
288
+ stdio: ["ignore", "pipe", "ignore"],
289
+ }).trimEnd();
290
+ [subject = "", date = ""] = raw.split("\0");
291
+ }
292
+ catch {
293
+ throw new Error(`G2 behavior decision ${decisionId} fixing commit metadata is unavailable`);
294
+ }
277
295
  let knownBad;
278
296
  try {
279
- knownBad = execFileSync("git", ["-C", root, "rev-parse", `${meta.sha}^`], {
297
+ knownBad = execFileSync("git", ["-C", root, "rev-parse", `${sha}^`], {
280
298
  encoding: "utf8",
299
+ env: replacementFreeGitEnvironment(),
281
300
  stdio: ["ignore", "pipe", "ignore"],
282
301
  }).trim();
283
302
  }
@@ -287,10 +306,10 @@ function directDecisionCommit(store, root, decisionId) {
287
306
  if (!FULL_SHA.test(knownBad))
288
307
  throw new Error(`G2 behavior decision ${decisionId} first parent is invalid`);
289
308
  return {
290
- commit: meta.sha,
291
- subject: meta.subject,
292
- date: meta.date,
293
- changedFiles: new Set(meta.files),
309
+ commit: sha,
310
+ subject,
311
+ date,
312
+ changedFiles: new Set(replacementFreeCommitFiles(root, sha)),
294
313
  decisionId: decision.id,
295
314
  knownBad,
296
315
  groundingHash: canonicalHash(decision),
@@ -360,7 +379,7 @@ function directDecisionReview(store, root, opts, behaviorResolutions, since, max
360
379
  if (resolution)
361
380
  candidate.human_review = resolution;
362
381
  }
363
- candidates.sort((left, right) => left.test.file.localeCompare(right.test.file) || left.test.name.localeCompare(right.test.name));
382
+ candidates.sort((left, right) => compareCodeUnits(left.test.file, right.test.file) || compareCodeUnits(left.test.name, right.test.name));
364
383
  const items = candidates.slice(0, limit);
365
384
  const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
366
385
  selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
@@ -425,9 +444,9 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
425
444
  const candidates = [];
426
445
  const failures = [];
427
446
  const withoutTests = [];
428
- for (const commit of [...commits.values()].sort((left, right) => left.commit.localeCompare(right.commit))) {
447
+ for (const commit of [...commits.values()].sort((left, right) => compareCodeUnits(left.commit, right.commit))) {
429
448
  let found = false;
430
- for (const file of [...commit.changedFiles].filter(safeTestFile).sort()) {
449
+ for (const file of [...commit.changedFiles].filter(safeTestFile).sort(compareCodeUnits)) {
431
450
  const after = fileAt(root, commit.commit, file);
432
451
  if (after == null) {
433
452
  failures.push({ commit: commit.commit, file, error: "known-good test source unavailable" });
@@ -447,9 +466,9 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
447
466
  statement: name,
448
467
  test: { file, name, source_hash: sourceHash },
449
468
  runner: runnerFor(file, name),
450
- decision_ids: [...commit.decisionIds].sort(),
451
- source_candidate_ids: [...commit.sourceCandidateIds].sort(),
452
- source_attestation_ids: [...commit.sourceAttestationIds].sort(),
469
+ decision_ids: [...commit.decisionIds].sort(compareCodeUnits),
470
+ source_candidate_ids: [...commit.sourceCandidateIds].sort(compareCodeUnits),
471
+ source_attestation_ids: [...commit.sourceAttestationIds].sort(compareCodeUnits),
453
472
  proposed_corpus: {
454
473
  known_bad: { ref: commit.knownBad, expected: "failed" },
455
474
  known_good: { ref: commit.commit, expected: "passed" },
@@ -471,10 +490,10 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
471
490
  if (resolution)
472
491
  candidate.human_review = resolution;
473
492
  }
474
- candidates.sort((left, right) => right.commit_date.localeCompare(left.commit_date)
475
- || left.commit.localeCompare(right.commit)
476
- || left.test.file.localeCompare(right.test.file)
477
- || left.test.name.localeCompare(right.test.name));
493
+ candidates.sort((left, right) => compareCodeUnits(right.commit_date, left.commit_date)
494
+ || compareCodeUnits(left.commit, right.commit)
495
+ || compareCodeUnits(left.test.file, right.test.file)
496
+ || compareCodeUnits(left.test.name, right.test.name));
478
497
  const items = candidates.slice(0, limit);
479
498
  const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
480
499
  selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
@@ -490,8 +509,8 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
490
509
  candidate_commits: commits.size,
491
510
  behavior_candidates: candidates.length,
492
511
  ...reviewCounts,
493
- commits_without_added_tests: withoutTests.sort(),
494
- extraction_failures: failures.sort((left, right) => left.commit.localeCompare(right.commit) || left.file.localeCompare(right.file)),
512
+ commits_without_added_tests: withoutTests.sort(compareCodeUnits),
513
+ extraction_failures: failures.sort((left, right) => compareCodeUnits(left.commit, right.commit) || compareCodeUnits(left.file, right.file)),
495
514
  items,
496
515
  has_more: candidates.length > items.length,
497
516
  limitations: LIMITATIONS,
@@ -523,12 +542,16 @@ export function nodeTestInfrastructureError(output) {
523
542
  return null;
524
543
  }
525
544
  function runLeg(root, session, hooks, env, candidate, commit, expected, source, budgetMs) {
526
- if (!FULL_SHA.test(commit) || !revExists(commit, root))
545
+ if (!FULL_SHA.test(commit) || replacementFreeExactCommit(root, commit) !== commit)
527
546
  return errorLeg(commit, expected, "commit-ref-unresolved");
528
547
  const run = mkdtempSync(join(session, `${expected}-`));
529
548
  const checkout = join(run, "checkout");
530
549
  const dependencySnapshot = dependencySnapshotForCommit(root, commit);
531
550
  const dependencySnapshotId = dependencySnapshot?.snapshot.id;
551
+ if (hasUnsafeCheckoutAttributes(root, commit, env, { allowDisabledLfs: true })) {
552
+ rmSync(run, { recursive: true, force: true });
553
+ return errorLeg(commit, expected, "unsafe-checkout-attributes", dependencySnapshotId);
554
+ }
532
555
  let added = false;
533
556
  let leg;
534
557
  try {
@@ -539,7 +562,7 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
539
562
  });
540
563
  added = true;
541
564
  if (dependencySnapshot) {
542
- symlinkSync(dependencySnapshot.nodeModules, join(checkout, "node_modules"), process.platform === "win32" ? "junction" : "dir");
565
+ materializeDependencySnapshot(dependencySnapshot, join(checkout, "node_modules"));
543
566
  }
544
567
  const testFile = join(checkout, candidate.test.file);
545
568
  mkdirSync(dirname(testFile), { recursive: true });
@@ -565,7 +588,7 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
565
588
  args = testArgs;
566
589
  }
567
590
  else {
568
- const tsx = dependencySnapshot ? join(dependencySnapshot.nodeModules, "tsx", "dist", "cli.mjs") : "";
591
+ const tsx = dependencySnapshot ? join(checkout, "node_modules", "tsx", "dist", "cli.mjs") : "";
569
592
  args = existsSync(tsx) ? [tsx, ...testArgs] : null;
570
593
  }
571
594
  if (!args) {
@@ -1,22 +1,207 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
4
- import { basename, join } from "node:path";
3
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { TextDecoder } from "node:util";
5
6
  import { shortHash } from "../core/ids.js";
7
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
6
8
  import { canonicalHash, canonicalJson } from "./canonical.js";
7
9
  import { replaySafeEnvironment } from "./replay.js";
10
+ import { replacementFreeExactCommit, replacementFreeGitEnvironment } from "./replacementFreeGit.js";
8
11
  const FULL_SHA = /^[a-f0-9]{40}$/;
9
12
  const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
10
- const SNAPSHOT_VERSION = 1;
13
+ // v1 bound only the installed lock and native binaries. Including the version
14
+ // in SnapshotInput makes those caches ineligible and provisions a fresh v2 tree.
15
+ const SNAPSHOT_VERSION = 2;
16
+ const TREE_HASH_VERSION = "hunch-node-modules-tree-v1";
17
+ const HASH_BUFFER_BYTES = 64 * 1024;
18
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
11
19
  function sha256(value) {
12
20
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
13
21
  }
14
- function gitFile(root, commit, file) {
15
- if (!FULL_SHA.test(commit))
16
- throw new Error(`dependency snapshot commit ${commit} is not a full SHA`);
22
+ function fileSha256(file) {
23
+ const hash = createHash("sha256");
24
+ const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
25
+ const fd = openSync(file, "r");
26
+ try {
27
+ for (let bytes = readSync(fd, buffer, 0, buffer.length, null); bytes > 0; bytes = readSync(fd, buffer, 0, buffer.length, null)) {
28
+ hash.update(buffer.subarray(0, bytes));
29
+ }
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ return `sha256:${hash.digest("hex")}`;
35
+ }
36
+ function hashField(hash, value) {
37
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
38
+ const length = Buffer.allocUnsafe(8);
39
+ length.writeBigUInt64BE(BigInt(bytes.length));
40
+ hash.update(length);
41
+ hash.update(bytes);
42
+ }
43
+ function safeEntryName(name) {
44
+ if (!name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (sep === "\\" && name.includes("\\"))) {
45
+ throw new Error("dependency snapshot contains an unsafe filesystem entry name");
46
+ }
47
+ return name;
48
+ }
49
+ function symlinkTarget(link) {
50
+ const raw = readlinkSync(link, { encoding: "buffer" });
51
+ try {
52
+ return UTF8_DECODER.decode(raw);
53
+ }
54
+ catch {
55
+ throw new Error("dependency snapshot contains a non-UTF-8 symlink target");
56
+ }
57
+ }
58
+ function inside(root, target) {
59
+ return target === root || target.startsWith(`${root}${sep}`);
60
+ }
61
+ function safeInternalSymlink(root, link, target) {
62
+ if (!target || target.includes("\0") || isAbsolute(target)) {
63
+ throw new Error("dependency snapshot contains an absolute or empty symlink target");
64
+ }
65
+ const lexicalRoot = resolve(root);
66
+ const lexicalTarget = resolve(dirname(link), target);
67
+ if (!inside(lexicalRoot, lexicalTarget)) {
68
+ throw new Error("dependency snapshot contains an escaping symlink target");
69
+ }
70
+ const realRoot = realpathSync(root);
71
+ const realTarget = realpathSync(lexicalTarget);
72
+ if (!inside(realRoot, realTarget)) {
73
+ throw new Error("dependency snapshot contains a symlink target outside node_modules");
74
+ }
75
+ const targetStat = statSync(link);
76
+ if (targetStat.isFile())
77
+ return "file";
78
+ if (targetStat.isDirectory())
79
+ return "dir";
80
+ throw new Error("dependency snapshot symlink resolves to a special filesystem entry");
81
+ }
82
+ function treeEntries(dir) {
83
+ return readdirSync(dir, { encoding: "buffer" })
84
+ .sort((left, right) => Buffer.compare(left, right))
85
+ .map((raw) => {
86
+ let name;
87
+ try {
88
+ name = UTF8_DECODER.decode(raw);
89
+ }
90
+ catch {
91
+ throw new Error("dependency snapshot contains a non-UTF-8 filesystem entry name");
92
+ }
93
+ return safeEntryName(name);
94
+ });
95
+ }
96
+ function treeRecord(hash, relative, kind, mode, payload = "") {
97
+ hashField(hash, relative);
98
+ hashField(hash, kind);
99
+ hashField(hash, (mode & 0o7777).toString(8).padStart(4, "0"));
100
+ hashField(hash, payload);
101
+ }
102
+ /** Hash every directory, regular file, executable bit, and internal symlink in
103
+ * a dependency tree. Traversal uses raw UTF-8 byte ordering rather than the
104
+ * host locale, and rejects filesystem shapes that cannot be copied safely. */
105
+ export function dependencySnapshotTreeHash(nodeModules) {
106
+ const rootStat = lstatSync(nodeModules);
107
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
108
+ throw new Error("dependency snapshot node_modules root is not a real directory");
109
+ }
110
+ const hash = createHash("sha256");
111
+ hashField(hash, TREE_HASH_VERSION);
112
+ treeRecord(hash, "", "directory", rootStat.mode);
113
+ const walk = (dir, relative) => {
114
+ for (const name of treeEntries(dir)) {
115
+ const absolute = join(dir, name);
116
+ const next = relative ? `${relative}/${name}` : name;
117
+ const stat = lstatSync(absolute);
118
+ if (stat.isSymbolicLink()) {
119
+ const target = symlinkTarget(absolute);
120
+ safeInternalSymlink(nodeModules, absolute, target);
121
+ treeRecord(hash, next, "symlink", stat.mode, target);
122
+ }
123
+ else if (stat.isDirectory()) {
124
+ treeRecord(hash, next, "directory", stat.mode);
125
+ walk(absolute, next);
126
+ }
127
+ else if (stat.isFile()) {
128
+ treeRecord(hash, next, "file", stat.mode, fileSha256(absolute));
129
+ }
130
+ else {
131
+ throw new Error(`dependency snapshot contains special filesystem entry ${next}`);
132
+ }
133
+ }
134
+ };
135
+ walk(nodeModules, "");
136
+ return `sha256:${hash.digest("hex")}`;
137
+ }
138
+ function copyDependencyTree(source, destination) {
139
+ const copyDirectory = (sourceDir, destinationDir) => {
140
+ const sourceStat = lstatSync(sourceDir);
141
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
142
+ throw new Error("dependency snapshot copy source is not a real directory");
143
+ }
144
+ mkdirSync(destinationDir, { mode: sourceStat.mode & 0o7777 });
145
+ for (const name of treeEntries(sourceDir)) {
146
+ const sourceEntry = join(sourceDir, name);
147
+ const destinationEntry = join(destinationDir, name);
148
+ const stat = lstatSync(sourceEntry);
149
+ if (stat.isSymbolicLink()) {
150
+ const target = symlinkTarget(sourceEntry);
151
+ const targetType = safeInternalSymlink(source, sourceEntry, target);
152
+ symlinkSync(target, destinationEntry, process.platform === "win32" ? targetType : undefined);
153
+ }
154
+ else if (stat.isDirectory()) {
155
+ copyDirectory(sourceEntry, destinationEntry);
156
+ }
157
+ else if (stat.isFile()) {
158
+ copyFileSync(sourceEntry, destinationEntry, fsConstants.COPYFILE_FICLONE);
159
+ chmodSync(destinationEntry, stat.mode & 0o7777);
160
+ }
161
+ else {
162
+ throw new Error("dependency snapshot contains a special filesystem entry");
163
+ }
164
+ }
165
+ chmodSync(destinationDir, sourceStat.mode & 0o7777);
166
+ };
167
+ copyDirectory(source, destination);
168
+ }
169
+ /** Create one independently writable dependency tree. COPYFILE_FICLONE is a
170
+ * copy-on-write optimization where supported, never a shared hardlink; the
171
+ * byte-copy fallback has the same isolation. Pre/post hashes bind the copy. */
172
+ export function materializeDependencyTree(source, destination, opts = {}) {
173
+ if (existsSync(destination))
174
+ throw new Error("dependency snapshot destination already exists");
175
+ const sourceHash = dependencySnapshotTreeHash(source);
176
+ if (opts.expectedHash && sourceHash !== opts.expectedHash) {
177
+ throw new Error("dependency tree hash mismatch before materialization");
178
+ }
179
+ try {
180
+ copyDependencyTree(source, destination);
181
+ if (dependencySnapshotTreeHash(destination) !== sourceHash) {
182
+ throw new Error("dependency tree hash mismatch after materialization");
183
+ }
184
+ return sourceHash;
185
+ }
186
+ catch (error) {
187
+ rmSync(destination, { recursive: true, force: true });
188
+ throw error;
189
+ }
190
+ }
191
+ /** Materialize a private writable dependency tree for one disposable run. */
192
+ export function materializeDependencySnapshot(dependency, destination) {
193
+ materializeDependencyTree(dependency.nodeModules, destination, {
194
+ expectedHash: dependency.snapshot.node_modules_hash,
195
+ });
196
+ }
197
+ function gitFile(root, commit, file, env) {
198
+ if (!FULL_SHA.test(commit) || replacementFreeExactCommit(root, commit) !== commit) {
199
+ throw new Error(`dependency snapshot commit ${commit} is not one exact commit`);
200
+ }
17
201
  try {
18
202
  return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
19
203
  encoding: "utf8",
204
+ env: replacementFreeGitEnvironment(env),
20
205
  maxBuffer: 20 * 1024 * 1024,
21
206
  stdio: ["ignore", "pipe", "ignore"],
22
207
  });
@@ -62,7 +247,7 @@ function lockedPackageNames(lock) {
62
247
  return names;
63
248
  }
64
249
  function normalizeAllowlist(values, lock) {
65
- const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
250
+ const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits);
66
251
  const locked = lockedPackageNames(lock);
67
252
  for (const name of result) {
68
253
  if (!PACKAGE_NAME.test(name))
@@ -104,8 +289,8 @@ function runtimeIdentity(env) {
104
289
  return { node: process.version, npm: result.stdout.trim(), platform: process.platform, arch: process.arch };
105
290
  }
106
291
  function snapshotInput(root, commit, allowInstallScripts, env) {
107
- const packageJson = gitFile(root, commit, "package.json");
108
- const packageLock = gitFile(root, commit, "package-lock.json");
292
+ const packageJson = gitFile(root, commit, "package.json", env);
293
+ const packageLock = gitFile(root, commit, "package-lock.json", env);
109
294
  const pkg = parseObject(packageJson, "package.json");
110
295
  const lock = parseObject(packageLock, "package-lock.json");
111
296
  if (lock.lockfileVersion !== 2 && lock.lockfileVersion !== 3) {
@@ -139,7 +324,7 @@ function snapshotInput(root, commit, allowInstallScripts, env) {
139
324
  function nativeInventory(nodeModules) {
140
325
  const files = [];
141
326
  const walk = (dir, relative) => {
142
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
327
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => compareCodeUnits(a.name, b.name))) {
143
328
  const absolute = join(dir, entry.name);
144
329
  const next = relative ? `${relative}/${entry.name}` : entry.name;
145
330
  if (entry.isSymbolicLink())
@@ -171,6 +356,8 @@ function readSnapshot(dir) {
171
356
  const installedLock = join(nodeModules, ".package-lock.json");
172
357
  if (!existsSync(nodeModules) || !lstatSync(nodeModules).isDirectory())
173
358
  return null;
359
+ if (dependencySnapshotTreeHash(nodeModules) !== value.node_modules_hash)
360
+ return null;
174
361
  const installedLockHash = existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256("");
175
362
  if (installedLockHash !== value.installed_lock_hash)
176
363
  return null;
@@ -232,6 +419,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
232
419
  const nodeModules = join(work, "node_modules");
233
420
  const installedLock = join(nodeModules, ".package-lock.json");
234
421
  mkdirSync(nodeModules, { recursive: true });
422
+ const nodeModulesHash = dependencySnapshotTreeHash(nodeModules);
235
423
  const body = {
236
424
  input_hash: input.inputHash,
237
425
  package_json_hash: input.packageJsonHash,
@@ -242,6 +430,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
242
430
  allow_install_scripts: input.allowInstallScripts,
243
431
  installed_lock_hash: existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256(""),
244
432
  native_binaries: nativeInventory(nodeModules),
433
+ node_modules_hash: nodeModulesHash,
245
434
  format_version: SNAPSHOT_VERSION,
246
435
  data_class: "private",
247
436
  authority: "none",
@@ -298,8 +487,8 @@ export function dependencySnapshotForCommit(root, commit, allowedIds) {
298
487
  let packageJson;
299
488
  let packageLock;
300
489
  try {
301
- packageJson = gitFile(root, commit, "package.json");
302
- packageLock = gitFile(root, commit, "package-lock.json");
490
+ packageJson = gitFile(root, commit, "package.json", env);
491
+ packageLock = gitFile(root, commit, "package-lock.json", env);
303
492
  }
304
493
  catch {
305
494
  return null;
@@ -343,7 +532,7 @@ export function provisionG2BehaviorDependencySnapshotsForCommits(root, commits,
343
532
  });
344
533
  return {
345
534
  snapshots: [...new Map([...byInput.values()].map((snapshot) => [snapshot.id, snapshot])).values()]
346
- .sort((left, right) => left.id.localeCompare(right.id)),
535
+ .sort((left, right) => compareCodeUnits(left.id, right.id)),
347
536
  commits: mapped,
348
537
  };
349
538
  }
@@ -367,7 +556,7 @@ export function provisionG2BehaviorDependencySnapshots(root, report, candidate,
367
556
  known_bad: { commit: candidate.proposed_corpus.known_bad.ref, dependency_snapshot_id: bad.dependency_snapshot_id },
368
557
  known_good: { commit: candidate.proposed_corpus.known_good.ref, dependency_snapshot_id: good.dependency_snapshot_id },
369
558
  },
370
- allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(),
559
+ allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits),
371
560
  data_class: "private",
372
561
  authority: "none",
373
562
  effects: "cache_only",
@@ -77,7 +77,7 @@ export function buildG2CandidateReview(store, root, opts = {}, resolutions = [])
77
77
  const graphStore = new HunchStore(hunchPathsForDir(scratchRoot));
78
78
  try {
79
79
  graphStore.json.ensureDirs();
80
- indexRepo(graphStore, root, { churn: false });
80
+ indexRepo(graphStore, root, { churn: false, requireComplete: true });
81
81
  return buildFromIndexedGraph(store, graphStore, root, opts, resolutions);
82
82
  }
83
83
  finally {
@@ -24,11 +24,25 @@ export function blockingEvidenceError(proof, dispositions = []) {
24
24
  return assessHistoryDispositions(proof, dispositions).blocking_error;
25
25
  }
26
26
  const proofRank = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, P5: 5 };
27
+ /** Activation is a runtime property, not merely an approval-time check. The
28
+ * audit fallback keeps correction policies compiled before `origin` was added
29
+ * fail-closed after they are loaded by the current schema. */
30
+ export function activationGateError(policy) {
31
+ const isMd1Correction = policy.origin === "correction_md1a"
32
+ || policy.audit.some((event) => event.action === "compiled" && event.actor === "hunch:correction-policy-materializer");
33
+ if (isMd1Correction && policy.activation_gate?.status !== "blocked") {
34
+ return "MD-1a correction policy is missing its required source-currentness activation gate";
35
+ }
36
+ return policy.activation_gate?.status === "blocked" ? policy.activation_gate.reason : null;
37
+ }
27
38
  /** Rechecked on every blocking evaluation; a hand-edited lifecycle flag without
28
39
  * a current P3 proof is a configuration error, never authority. */
29
40
  export function blockingProofError(policy, proof, dispositions = [], composition = [], currentBehaviorAttestations = []) {
30
41
  if (policy.state !== "active_blocking")
31
42
  return null;
43
+ const activationError = activationGateError(policy);
44
+ if (activationError)
45
+ return activationError;
32
46
  if (policy.authority?.kind !== "human")
33
47
  return "active blocking policy has no human authority event";
34
48
  const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
@@ -95,6 +109,9 @@ export function proposeProvedPolicy(policy, proof, at, composition = [], current
95
109
  };
96
110
  }
97
111
  export function approvePolicy(policy, proof, mode, actor, at, dispositions = [], composition = [], currentBehaviorAttestations = []) {
112
+ const activationError = activationGateError(policy);
113
+ if (activationError)
114
+ throw new Error(`policy ${policy.id} cannot activate: ${activationError}`);
98
115
  requireHuman(actor);
99
116
  const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
100
117
  if (behaviorAttestationError)