tamperward 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +238 -60
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -202,6 +202,27 @@ function parseHunk(lines, start) {
202
202
  return { hunk: { oldStart, oldLines, newStart, newLines, lines: out2 }, next: i };
203
203
  }
204
204
 
205
+ // src/diff/synth.ts
206
+ function linesOf(content) {
207
+ if (content === "") return [];
208
+ const lines = content.split("\n");
209
+ if (content.endsWith("\n")) lines.pop();
210
+ return lines;
211
+ }
212
+ function addHunks(content) {
213
+ const lines = linesOf(content);
214
+ if (lines.length === 0) return [];
215
+ return [
216
+ {
217
+ oldStart: 0,
218
+ oldLines: 0,
219
+ newStart: 1,
220
+ newLines: lines.length,
221
+ lines: lines.map((l, i) => ({ type: "add", content: l, oldLine: null, newLine: i + 1 }))
222
+ }
223
+ ];
224
+ }
225
+
205
226
  // src/git/build.ts
206
227
  function git(args, cwd) {
207
228
  try {
@@ -256,6 +277,9 @@ function mergeBaseOf(base, head, opts = {}) {
256
277
  function fileAt(rev, path, opts = {}) {
257
278
  return blobAt(rev, path, opts.cwd);
258
279
  }
280
+ function fileOnDisk(path, opts = {}) {
281
+ return fromDisk(path, opts.cwd);
282
+ }
259
283
  function isGitRepo(cwd) {
260
284
  try {
261
285
  git(["rev-parse", "--git-dir"], cwd);
@@ -306,18 +330,35 @@ function diffWorktree(opts = {}) {
306
330
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
307
331
  );
308
332
  }
333
+ function untrackedAdds(opts = {}, keep, exclude) {
334
+ const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
335
+ return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => {
336
+ const after = fromDisk(rel, opts.cwd);
337
+ return {
338
+ kind: "file",
339
+ path: rel,
340
+ oldPath: null,
341
+ op: "add",
342
+ before: null,
343
+ after,
344
+ binary: false,
345
+ hunks: after == null ? [] : addHunks(after)
346
+ };
347
+ });
348
+ }
309
349
  function diffWorktreeWithUntracked(opts = {}) {
310
350
  const tracked = diffWorktree(opts);
311
- const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
312
351
  const seen = new Set(tracked.filter((c) => c.kind === "file").map((c) => c.path));
313
- const untracked = others.filter((rel) => !seen.has(rel)).map(
314
- (rel) => enrich(
315
- { kind: "file", path: rel, oldPath: null, op: "add", before: null, after: null, binary: false, hunks: [] },
316
- () => null,
317
- (p) => fromDisk(p, opts.cwd)
318
- )
319
- );
320
- return [...tracked, ...untracked];
352
+ return [...tracked, ...untrackedAdds(opts, void 0, seen)];
353
+ }
354
+ function hiddenTrackedPaths(opts = {}) {
355
+ const out2 = [];
356
+ for (const entry of git(["ls-files", "-v", "-z"], opts.cwd).split("\0")) {
357
+ if (entry.length < 3 || entry[1] !== " ") continue;
358
+ const tag = entry[0];
359
+ if (tag === "S" || tag !== tag.toUpperCase()) out2.push(entry.slice(2));
360
+ }
361
+ return out2;
321
362
  }
322
363
 
323
364
  // src/detectors/finding.ts
@@ -947,8 +988,20 @@ var PATTERNS2 = {
947
988
  py: [
948
989
  // `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
949
990
  { re: /@\w+\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
991
+ // `from pytest import mark` makes it `@mark.skip`; `marks=pytest.mark.skip` is the
992
+ // parametrize form. Both carry the same marker without the `@<module>.` shape.
993
+ { re: /@mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker (from pytest import mark)" },
994
+ { re: /\bmarks\s*=\s*(?:\w+\.)?mark\.(?:skip|skipif|xfail)\b/, why: "a parametrize marks= skip/skipif/xfail marker" },
950
995
  { re: /\bpytestmark\s*=.*\.mark\.(?:skip|skipif|xfail)\b/, why: "a module-level pytestmark skip (every test in the file is skipped)" },
951
- { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
996
+ { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail()/importorskip() call" },
997
+ // Collection-time drops. A conftest.py is protected as a test file, and these
998
+ // are how pytest is told never to collect a test in the first place — no
999
+ // marker ever appears in the test itself. `collect_ignore` / `collect_ignore_glob`
1000
+ // are conftest globals (pytest docs, "Customizing test collection");
1001
+ // `pytest_ignore_collect` is the hook whose only job is to say "skip this path";
1002
+ // `__test__ = False` de-collects a class or function.
1003
+ { re: /\bcollect_ignore(?:_glob)?\s*(?:=|\+=|\.(?:append|extend)\()/, why: "a collect_ignore / collect_ignore_glob entry (the files are never collected)" },
1004
+ { re: /\bdef\s+pytest_ignore_collect\s*\(/, why: "a pytest_ignore_collect hook (paths are dropped at collection)" },
952
1005
  // `from unittest import skip` makes the decorator bare `@skip`.
953
1006
  { re: /@(?:unittest\.)?(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
954
1007
  { re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
@@ -1162,12 +1215,12 @@ var coverageLowering = {
1162
1215
  );
1163
1216
  }
1164
1217
  for (const l of added) {
1165
- if (/--passWithNoTests\b/.test(l.content)) {
1218
+ if (/--passWithNoTests\b/.test(l.content) || /\bpassWithNoTests\s*:\s*true\b/.test(l.content)) {
1166
1219
  out2.push(
1167
1220
  makeFinding(RULE4, policy, {
1168
1221
  file: c.path,
1169
1222
  line: l.newLine ?? void 0,
1170
- message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
1223
+ message: "passWithNoTests added \u2014 the suite can now pass with zero tests.",
1171
1224
  evidence: l.content.trim(),
1172
1225
  remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1173
1226
  })
@@ -2476,6 +2529,7 @@ function runCheck(opts) {
2476
2529
  // src/cli/hook.ts
2477
2530
  import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
2478
2531
  import { createHash as createHash4 } from "node:crypto";
2532
+ import { join as join8 } from "node:path";
2479
2533
 
2480
2534
  // src/adapters/claude/changes.ts
2481
2535
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -2658,14 +2712,23 @@ function gitDir2(cwd) {
2658
2712
  return null;
2659
2713
  }
2660
2714
  }
2661
- function ptreePath(cwd, sessionId) {
2715
+ function statePath(cwd, sessionId, kind) {
2662
2716
  if (!sessionId) return null;
2663
2717
  const gd = gitDir2(cwd);
2664
2718
  if (!gd) return null;
2665
2719
  const dir = join6(gd, "tamperward");
2666
- return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
2720
+ return join6(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
2721
+ }
2722
+ function ptreePath(cwd, sessionId) {
2723
+ return statePath(cwd, sessionId, "ptree");
2724
+ }
2725
+ function turnTreePath(cwd, sessionId) {
2726
+ return statePath(cwd, sessionId, "turntree");
2667
2727
  }
2668
2728
  var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
2729
+ function contentHash(content) {
2730
+ return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
2731
+ }
2669
2732
  function snapshotProtected(cwd, policy, prev) {
2670
2733
  const out2 = {};
2671
2734
  const walk = (rel) => {
@@ -2710,8 +2773,7 @@ function driftBetween(expected, current) {
2710
2773
  }
2711
2774
  return { changed, deleted };
2712
2775
  }
2713
- function loadPtree(cwd, sessionId) {
2714
- const p = ptreePath(cwd, sessionId);
2776
+ function loadTree(p) {
2715
2777
  if (!p || !existsSync5(p)) return null;
2716
2778
  try {
2717
2779
  return JSON.parse(readFileSync6(p, "utf8"));
@@ -2719,8 +2781,7 @@ function loadPtree(cwd, sessionId) {
2719
2781
  return null;
2720
2782
  }
2721
2783
  }
2722
- function savePtree(cwd, sessionId, tree) {
2723
- const p = ptreePath(cwd, sessionId);
2784
+ function saveTree(p, tree) {
2724
2785
  if (!p) return;
2725
2786
  try {
2726
2787
  mkdirSync3(join6(p, ".."), { recursive: true });
@@ -2728,6 +2789,18 @@ function savePtree(cwd, sessionId, tree) {
2728
2789
  } catch {
2729
2790
  }
2730
2791
  }
2792
+ function loadPtree(cwd, sessionId) {
2793
+ return loadTree(ptreePath(cwd, sessionId));
2794
+ }
2795
+ function savePtree(cwd, sessionId, tree) {
2796
+ saveTree(ptreePath(cwd, sessionId), tree);
2797
+ }
2798
+ function loadTurnTree(cwd, sessionId) {
2799
+ return loadTree(turnTreePath(cwd, sessionId));
2800
+ }
2801
+ function saveTurnTree(cwd, sessionId, tree) {
2802
+ saveTree(turnTreePath(cwd, sessionId), tree);
2803
+ }
2731
2804
 
2732
2805
  // src/cli/watch.ts
2733
2806
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
@@ -2951,12 +3024,107 @@ function verdict(blocks, kind) {
2951
3024
  } : { decision: "block", reason };
2952
3025
  return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
2953
3026
  }
3027
+ function fileChanges(changes) {
3028
+ return changes.filter((c) => c.kind === "file");
3029
+ }
3030
+ function hashOnDisk(cwd, rel) {
3031
+ try {
3032
+ return contentHash(readFileSync9(join8(cwd, rel)));
3033
+ } catch {
3034
+ return null;
3035
+ }
3036
+ }
3037
+ function gitMode(mode) {
3038
+ return (mode & 64) !== 0 ? "100755" : "100644";
3039
+ }
3040
+ function turnView(cwd, sessionId, policy, base) {
3041
+ const tracked = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3042
+ const seen = /* @__PURE__ */ new Set();
3043
+ for (const c of fileChanges(tracked)) {
3044
+ seen.add(c.path);
3045
+ if (c.oldPath) seen.add(c.oldPath);
3046
+ }
3047
+ const turnTree = loadTurnTree(cwd, sessionId);
3048
+ const untracked = untrackedAdds({ cwd }, (rel) => isProtected(rel, policy), seen).filter(
3049
+ (c) => c.kind !== "file" || !turnTree || turnTree[c.path]?.hash !== hashOnDisk(cwd, c.path)
3050
+ );
3051
+ for (const c of fileChanges(untracked)) seen.add(c.path);
3052
+ const trusted = base ?? "HEAD";
3053
+ const hidden = [];
3054
+ for (const rel of hiddenTrackedPaths({ cwd })) {
3055
+ if (seen.has(rel) || !isProtected(rel, policy)) continue;
3056
+ hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
3057
+ }
3058
+ return [...tracked, ...untracked, ...hidden];
3059
+ }
3060
+ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
3061
+ const d = driftBetween(expected, current);
3062
+ const seen = /* @__PURE__ */ new Set();
3063
+ for (const c of fileChanges(covered)) {
3064
+ seen.add(c.path);
3065
+ if (c.oldPath) seen.add(c.oldPath);
3066
+ }
3067
+ const changes = [];
3068
+ const blocks = [];
3069
+ const trusted = base ?? "HEAD";
3070
+ const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
3071
+ for (const rel of [...d.changed, ...d.deleted]) {
3072
+ if (seen.has(rel)) continue;
3073
+ const after = fileOnDisk(rel, { cwd });
3074
+ const trustedBlob = fileAt(trusted, rel, { cwd });
3075
+ const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
3076
+ const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
3077
+ if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
3078
+ continue;
3079
+ }
3080
+ const want = expected[rel]?.hash;
3081
+ let before = null;
3082
+ for (const rev of candidates) {
3083
+ const blob = fileAt(rev, rel, { cwd });
3084
+ if (blob != null && contentHash(blob) === want) {
3085
+ before = blob;
3086
+ break;
3087
+ }
3088
+ }
3089
+ if (before == null) {
3090
+ blocks.push({
3091
+ rule: "hidden-drift",
3092
+ severity: "block",
3093
+ file: rel,
3094
+ message: `Protected file ${rel} changed outside git's view, and its sanctioned content cannot be reconstructed to judge the change.`,
3095
+ evidence: rel,
3096
+ remediation: "Restore the file to its last sanctioned content (git restore, or undo the edit) and bring it back into git's view (git update-index --no-skip-worktree / --no-assume-unchanged); make the change through a tool call the gate can see.",
3097
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
3098
+ });
3099
+ continue;
3100
+ }
3101
+ const synth = synthFileChange(rel, before, after);
3102
+ if (synth.length > 0) {
3103
+ changes.push(...synth);
3104
+ } else if (after != null && current[rel] && expected[rel] && current[rel].mode !== expected[rel].mode) {
3105
+ changes.push({
3106
+ kind: "file",
3107
+ path: rel,
3108
+ oldPath: null,
3109
+ op: "modify",
3110
+ before,
3111
+ after,
3112
+ binary: false,
3113
+ hunks: [],
3114
+ oldMode: gitMode(expected[rel].mode),
3115
+ newMode: gitMode(current[rel].mode)
3116
+ });
3117
+ }
3118
+ }
3119
+ return { changes, blocks };
3120
+ }
2954
3121
  function effectDriftBlocks(cwd, sessionId, policy) {
2955
3122
  if (!sessionId || !isGitRepo(cwd)) return null;
2956
3123
  const expected = loadPtree(cwd, sessionId);
2957
3124
  const current = snapshotProtected(cwd, policy, expected ?? void 0);
2958
3125
  if (!expected) {
2959
3126
  savePtree(cwd, sessionId, current);
3127
+ if (!loadTurnTree(cwd, sessionId)) saveTurnTree(cwd, sessionId, current);
2960
3128
  return null;
2961
3129
  }
2962
3130
  const d = driftBetween(expected, current);
@@ -2965,12 +3133,13 @@ function effectDriftBlocks(cwd, sessionId, policy) {
2965
3133
  return null;
2966
3134
  }
2967
3135
  const base = turnBaseline(cwd, sessionId);
2968
- const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3136
+ const all = turnView(cwd, sessionId, policy, base);
2969
3137
  const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
2970
3138
  const scoped = all.filter(
2971
3139
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
2972
3140
  );
2973
- const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
3141
+ const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
3142
+ const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn").filter((f) => f.severity === "block").concat(gap.blocks);
2974
3143
  if (blocks.length > 0) return blocks;
2975
3144
  savePtree(cwd, sessionId, current);
2976
3145
  return null;
@@ -3071,13 +3240,22 @@ function stopVerdict(input) {
3071
3240
  try {
3072
3241
  const policy = loadPolicy(cwd);
3073
3242
  const base = turnBaseline(cwd, input.session_id);
3074
- const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3075
- blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
3243
+ let changes = turnView(cwd, input.session_id, policy, base);
3244
+ const expected = loadPtree(cwd, input.session_id);
3245
+ const current = input.session_id ? snapshotProtected(cwd, policy, expected ?? void 0) : null;
3246
+ let hiddenBlocks = [];
3247
+ if (expected && current) {
3248
+ const gap = uncoveredDrift(cwd, policy, expected, current, changes, base, loadTurnTree(cwd, input.session_id));
3249
+ changes = changes.concat(gap.changes);
3250
+ hiddenBlocks = gap.blocks;
3251
+ }
3252
+ blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block").concat(hiddenBlocks);
3076
3253
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
3077
3254
  blocks = blocks.concat(transient.blocks);
3078
3255
  commitCursor = transient.commit;
3079
- if (blocks.length === 0 && input.session_id) {
3080
- savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
3256
+ if (blocks.length === 0 && input.session_id && current) {
3257
+ savePtree(cwd, input.session_id, current);
3258
+ saveTurnTree(cwd, input.session_id, current);
3081
3259
  }
3082
3260
  } catch (e) {
3083
3261
  return failClosed("Stop", errText(e));
@@ -3171,7 +3349,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3171
3349
  import { execFileSync as execFileSync4 } from "node:child_process";
3172
3350
  import { createHash as createHash5 } from "node:crypto";
3173
3351
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3174
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
3352
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join9, relative as relative2, resolve as resolve2 } from "node:path";
3175
3353
  import { fileURLToPath } from "node:url";
3176
3354
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3177
3355
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -3236,7 +3414,7 @@ function shippedVersion() {
3236
3414
  const here = dirname3(fileURLToPath(import.meta.url));
3237
3415
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
3238
3416
  try {
3239
- const pkg = JSON.parse(readFileSync10(join8(here, rel), "utf8"));
3417
+ const pkg = JSON.parse(readFileSync10(join9(here, rel), "utf8"));
3240
3418
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
3241
3419
  } catch {
3242
3420
  }
@@ -3369,7 +3547,7 @@ function hooksShapeError(hooks) {
3369
3547
  return null;
3370
3548
  }
3371
3549
  function planPolicy(cwd) {
3372
- const path = join8(cwd, POLICY_FILE);
3550
+ const path = join9(cwd, POLICY_FILE);
3373
3551
  if (existsSync7(path)) {
3374
3552
  try {
3375
3553
  loadPolicy(cwd);
@@ -3388,7 +3566,7 @@ function planPolicy(cwd) {
3388
3566
  }
3389
3567
  function planClaudeHooks(cwd) {
3390
3568
  const rel = ".claude/settings.json";
3391
- const path = join8(cwd, rel);
3569
+ const path = join9(cwd, rel);
3392
3570
  let settings = {};
3393
3571
  if (existsSync7(path)) {
3394
3572
  let parsed;
@@ -3486,7 +3664,7 @@ function hooksDir(cwd) {
3486
3664
  if (out2) return resolve2(cwd, out2);
3487
3665
  } catch {
3488
3666
  }
3489
- return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
3667
+ return existsSync7(join9(cwd, ".git")) ? join9(cwd, ".git", "hooks") : null;
3490
3668
  }
3491
3669
  function display(cwd, path) {
3492
3670
  const rel = relative2(cwd, path);
@@ -3513,23 +3691,23 @@ function planPreCommit(cwd) {
3513
3691
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3514
3692
  let path;
3515
3693
  let note;
3516
- if (existsSync7(join8(cwd, ".husky"))) {
3517
- path = join8(cwd, ".husky", "pre-commit");
3694
+ if (existsSync7(join9(cwd, ".husky"))) {
3695
+ path = join9(cwd, ".husky", "pre-commit");
3518
3696
  note = "husky";
3519
3697
  } else {
3520
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
3698
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join9(cwd, c))));
3521
3699
  if (managed) {
3522
3700
  return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
3523
3701
  }
3524
3702
  const dir = hooksDir(cwd);
3525
3703
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3526
3704
  if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3527
- path = join8(dirname3(dir), "pre-commit");
3705
+ path = join9(dirname3(dir), "pre-commit");
3528
3706
  note = "husky";
3529
3707
  } else {
3530
- path = join8(dir, "pre-commit");
3708
+ path = join9(dir, "pre-commit");
3531
3709
  const rel2 = display(cwd, dir);
3532
- note = rel2 === join8(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
3710
+ note = rel2 === join9(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
3533
3711
  }
3534
3712
  }
3535
3713
  const rel = display(cwd, path);
@@ -3625,9 +3803,9 @@ ${block.join("\n")}
3625
3803
  function planCodeowners(cwd) {
3626
3804
  const rel = ".github/CODEOWNERS";
3627
3805
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
3628
- (r) => existsSync7(join8(cwd, r))
3806
+ (r) => existsSync7(join9(cwd, r))
3629
3807
  );
3630
- const path = join8(cwd, existingRel ?? rel);
3808
+ const path = join9(cwd, existingRel ?? rel);
3631
3809
  const existing = existingRel ? readFileSync10(path, "utf8") : null;
3632
3810
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
3633
3811
  if (missing.length === 0) {
@@ -3661,7 +3839,7 @@ ${CODEOWNERS_MARK}
3661
3839
  }
3662
3840
  function planWorkflow(cwd, force) {
3663
3841
  const rel = ".github/workflows/tamperward.yml";
3664
- const path = join8(cwd, rel);
3842
+ const path = join9(cwd, rel);
3665
3843
  const write = () => {
3666
3844
  mkdirSync5(dirname3(path), { recursive: true });
3667
3845
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
@@ -3778,13 +3956,13 @@ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3778
3956
  import { createHash as createHash8 } from "node:crypto";
3779
3957
  import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3780
3958
  import { tmpdir as tmpdir2 } from "node:os";
3781
- import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
3959
+ import { dirname as dirname4, join as join12, resolve as resolve3 } from "node:path";
3782
3960
 
3783
3961
  // src/git/trusted.ts
3784
3962
  import { execFileSync as execFileSync5 } from "node:child_process";
3785
3963
  import { createHash as createHash6 } from "node:crypto";
3786
3964
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3787
- import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
3965
+ import { isAbsolute as isAbsolute5, join as join10 } from "node:path";
3788
3966
  function trustedGitEnv() {
3789
3967
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3790
3968
  }
@@ -3797,7 +3975,7 @@ function gitDirOf(cwd) {
3797
3975
  env: trustedGitEnv()
3798
3976
  }).trim();
3799
3977
  if (!d) return null;
3800
- return isAbsolute5(d) ? d : join9(cwd, d);
3978
+ return isAbsolute5(d) ? d : join10(cwd, d);
3801
3979
  } catch {
3802
3980
  return null;
3803
3981
  }
@@ -3822,7 +4000,7 @@ function objectRewriteState(cwd) {
3822
4000
  parts.push(`${rel}:<no-git-dir>`);
3823
4001
  continue;
3824
4002
  }
3825
- const p = join9(dir, rel);
4003
+ const p = join10(dir, rel);
3826
4004
  try {
3827
4005
  parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
3828
4006
  } catch {
@@ -3836,7 +4014,7 @@ function objectRewriteState(cwd) {
3836
4014
  import { execFileSync as execFileSync6 } from "node:child_process";
3837
4015
  import { createHash as createHash7 } from "node:crypto";
3838
4016
  import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
3839
- import { join as join10 } from "node:path";
4017
+ import { join as join11 } from "node:path";
3840
4018
  function treeFingerprint(cwd) {
3841
4019
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3842
4020
  cwd,
@@ -3849,7 +4027,7 @@ function treeFingerprint(cwd) {
3849
4027
  h.update(rel);
3850
4028
  h.update("\0");
3851
4029
  try {
3852
- h.update(readFileSync12(join10(cwd, rel)));
4030
+ h.update(readFileSync12(join11(cwd, rel)));
3853
4031
  } catch {
3854
4032
  h.update("<unreadable>");
3855
4033
  }
@@ -3858,7 +4036,7 @@ function treeFingerprint(cwd) {
3858
4036
  return h.digest("hex");
3859
4037
  }
3860
4038
  function depsFingerprint(cwd) {
3861
- const root = join10(cwd, "node_modules");
4039
+ const root = join11(cwd, "node_modules");
3862
4040
  const h = createHash7("sha256");
3863
4041
  const walk = (dir) => {
3864
4042
  let entries;
@@ -3868,7 +4046,7 @@ function depsFingerprint(cwd) {
3868
4046
  return;
3869
4047
  }
3870
4048
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3871
- const p = join10(dir, e.name);
4049
+ const p = join11(dir, e.name);
3872
4050
  if (e.isDirectory()) walk(p);
3873
4051
  else if (e.isFile()) {
3874
4052
  h.update(p);
@@ -3988,7 +4166,7 @@ function baseIsAncestorOfHead(base, cwd) {
3988
4166
  function materialize(cwd, dest) {
3989
4167
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
3990
4168
  for (const rel of listed) {
3991
- const src = join11(cwd, rel);
4169
+ const src = join12(cwd, rel);
3992
4170
  let st;
3993
4171
  try {
3994
4172
  st = statSync7(src);
@@ -3996,7 +4174,7 @@ function materialize(cwd, dest) {
3996
4174
  continue;
3997
4175
  }
3998
4176
  if (!st.isFile()) continue;
3999
- const out2 = join11(dest, rel);
4177
+ const out2 = join12(dest, rel);
4000
4178
  mkdirSync6(dirname4(out2), { recursive: true });
4001
4179
  dropSymlink(out2);
4002
4180
  if (lstatSync(src).isSymbolicLink()) {
@@ -4007,7 +4185,7 @@ function materialize(cwd, dest) {
4007
4185
  chmodSync2(out2, st.mode);
4008
4186
  }
4009
4187
  const nm = resolve3(cwd, "node_modules");
4010
- if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
4188
+ if (existsSync9(nm)) symlinkSync(nm, join12(dest, "node_modules"), "dir");
4011
4189
  }
4012
4190
  function dropSymlink(p) {
4013
4191
  try {
@@ -4040,7 +4218,7 @@ function overlayDigest(dest, paths) {
4040
4218
  for (const rel of [...paths].sort()) {
4041
4219
  h.update(rel);
4042
4220
  h.update("\0");
4043
- const p = join11(dest, rel);
4221
+ const p = join12(dest, rel);
4044
4222
  try {
4045
4223
  const st = lstatSync(p);
4046
4224
  h.update(String(st.mode));
@@ -4067,7 +4245,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4067
4245
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4068
4246
  }
4069
4247
  const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4070
- const out2 = join11(dest, e.path);
4248
+ const out2 = join12(dest, e.path);
4071
4249
  mkdirSync6(dirname4(out2), { recursive: true });
4072
4250
  rmSync2(out2, { force: true });
4073
4251
  if (e.mode === "120000") {
@@ -4082,7 +4260,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4082
4260
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4083
4261
  for (const rel of inCopy) {
4084
4262
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4085
- rmSync2(join11(dest, rel), { force: true });
4263
+ rmSync2(join12(dest, rel), { force: true });
4086
4264
  removed++;
4087
4265
  }
4088
4266
  return { restored, removed };
@@ -4124,8 +4302,8 @@ function suiteEnv(scratch) {
4124
4302
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4125
4303
  env[k] = v;
4126
4304
  }
4127
- const userRc = join11(scratch, "npmrc-user");
4128
- const globalRc = join11(scratch, "npmrc-global");
4305
+ const userRc = join12(scratch, "npmrc-user");
4306
+ const globalRc = join12(scratch, "npmrc-global");
4129
4307
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4130
4308
  env.npm_config_userconfig = userRc;
4131
4309
  env.npm_config_globalconfig = globalRc;
@@ -4135,8 +4313,8 @@ function suiteEnv(scratch) {
4135
4313
  }
4136
4314
  function runSuite(dir, cmd, budgetSecs) {
4137
4315
  const t0 = Date.now();
4138
- const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
4139
- const outFile = join11(outDir, "result.json");
4316
+ const outDir = mkdtempSync2(join12(tmpdir2(), "tw-verify-run-"));
4317
+ const outFile = join12(outDir, "result.json");
4140
4318
  try {
4141
4319
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
4142
4320
  cwd: dir,
@@ -4200,8 +4378,8 @@ function runVerify(opts) {
4200
4378
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
4201
4379
  return 2;
4202
4380
  }
4203
- const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
4204
- const visDir = join11(visRoot, "t");
4381
+ const visRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-vis-"));
4382
+ const visDir = join12(visRoot, "t");
4205
4383
  const cleanup = (dirs) => {
4206
4384
  if (opts.keep) return;
4207
4385
  for (const d of dirs) rmSync2(d, { recursive: true, force: true });
@@ -4229,8 +4407,8 @@ function runVerify(opts) {
4229
4407
  out2("pristine run executes through it too. Failing closed, not open.");
4230
4408
  return 2;
4231
4409
  }
4232
- const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
4233
- const priDir = join11(priRoot, "t");
4410
+ const priRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-pri-"));
4411
+ const priDir = join12(priRoot, "t");
4234
4412
  let restored = [];
4235
4413
  let removedAdded = 0;
4236
4414
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",