tamperward 2.2.1 → 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 +332 -63
  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);
@@ -3907,18 +4085,62 @@ var VERIFICATION_SURFACE = [
3907
4085
  "**/setup.cfg",
3908
4086
  "**/tox.ini",
3909
4087
  "**/pyproject.toml",
4088
+ "**/setup.py",
4089
+ // a pytest rootdir marker, and what an editable install executes
4090
+ // Interpreter start-up hooks: imported by site.py before pytest exists,
4091
+ // wherever the tree reaches sys.path (PYTHONPATH=., an editable install).
4092
+ "**/sitecustomize.py",
4093
+ "**/usercustomize.py",
3910
4094
  // JavaScript / TypeScript runners and the transforms they load
3911
4095
  "**/jest.config.*",
3912
4096
  "**/jest.setup.*",
3913
4097
  "**/vitest.config.*",
3914
4098
  "**/vitest.workspace.*",
3915
4099
  "**/.mocharc.*",
4100
+ "**/mocha.opts",
3916
4101
  "**/karma.conf.*",
4102
+ "**/ava.config.*",
4103
+ "**/.taprc",
4104
+ "**/spec/support/jasmine.json",
4105
+ "**/playwright.config.*",
4106
+ "**/cypress.config.*",
4107
+ "**/.nycrc*",
4108
+ "**/.c8rc*",
3917
4109
  "**/babel.config.*",
3918
4110
  "**/.babelrc*",
3919
4111
  "**/vite.config.*",
3920
4112
  "**/.swcrc",
3921
4113
  "**/package.json",
4114
+ // Package-manager configuration: read before the runner starts, and able to
4115
+ // choose what the runner is started under (`node-options`), which shell runs
4116
+ // the script (`script-shell`), or which program is run at all (`yarn-path`).
4117
+ "**/.npmrc",
4118
+ "**/.yarnrc",
4119
+ "**/.yarnrc.yml",
4120
+ // pnpm 10 reads its settings (`nodeOptions` among them) from here as well,
4121
+ // and its environment layer does NOT outrank this file (verified, pnpm
4122
+ // 10.33) — so the file list is the only control for it.
4123
+ "**/pnpm-workspace.yaml",
4124
+ "**/.pnpmfile.cjs",
4125
+ // The runner the package manager executes when the project pins one: yarn's
4126
+ // `yarnPath` release and its plugins, and the PnP loader every spawned node
4127
+ // is made to `--require`.
4128
+ "**/.yarn/releases/*",
4129
+ "**/.yarn/plugins/**",
4130
+ "**/.pnp.cjs",
4131
+ "**/.pnp.js",
4132
+ "**/.pnp.loader.mjs",
4133
+ // dotenv files: `node --env-file`, a jest/vitest setup that loads dotenv,
4134
+ // pytest-dotenv. An .env carries NODE_OPTIONS as easily as an .npmrc does.
4135
+ "**/.env",
4136
+ "**/.env.*",
4137
+ // Version-manager pins: which interpreter the shims select for this cwd.
4138
+ "**/.nvmrc",
4139
+ "**/.node-version",
4140
+ "**/.python-version",
4141
+ // What git does to content on its way into and out of the tree — the base's
4142
+ // decision, never the candidate's.
4143
+ "**/.gitattributes",
3922
4144
  // Ruby / PHP / .NET
3923
4145
  "**/.rspec",
3924
4146
  "**/phpunit.xml",
@@ -3944,7 +4166,7 @@ function baseIsAncestorOfHead(base, cwd) {
3944
4166
  function materialize(cwd, dest) {
3945
4167
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
3946
4168
  for (const rel of listed) {
3947
- const src = join11(cwd, rel);
4169
+ const src = join12(cwd, rel);
3948
4170
  let st;
3949
4171
  try {
3950
4172
  st = statSync7(src);
@@ -3952,7 +4174,7 @@ function materialize(cwd, dest) {
3952
4174
  continue;
3953
4175
  }
3954
4176
  if (!st.isFile()) continue;
3955
- const out2 = join11(dest, rel);
4177
+ const out2 = join12(dest, rel);
3956
4178
  mkdirSync6(dirname4(out2), { recursive: true });
3957
4179
  dropSymlink(out2);
3958
4180
  if (lstatSync(src).isSymbolicLink()) {
@@ -3963,7 +4185,7 @@ function materialize(cwd, dest) {
3963
4185
  chmodSync2(out2, st.mode);
3964
4186
  }
3965
4187
  const nm = resolve3(cwd, "node_modules");
3966
- if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
4188
+ if (existsSync9(nm)) symlinkSync(nm, join12(dest, "node_modules"), "dir");
3967
4189
  }
3968
4190
  function dropSymlink(p) {
3969
4191
  try {
@@ -3996,7 +4218,7 @@ function overlayDigest(dest, paths) {
3996
4218
  for (const rel of [...paths].sort()) {
3997
4219
  h.update(rel);
3998
4220
  h.update("\0");
3999
- const p = join11(dest, rel);
4221
+ const p = join12(dest, rel);
4000
4222
  try {
4001
4223
  const st = lstatSync(p);
4002
4224
  h.update(String(st.mode));
@@ -4023,7 +4245,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4023
4245
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4024
4246
  }
4025
4247
  const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4026
- const out2 = join11(dest, e.path);
4248
+ const out2 = join12(dest, e.path);
4027
4249
  mkdirSync6(dirname4(out2), { recursive: true });
4028
4250
  rmSync2(out2, { force: true });
4029
4251
  if (e.mode === "120000") {
@@ -4038,7 +4260,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4038
4260
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4039
4261
  for (const rel of inCopy) {
4040
4262
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4041
- rmSync2(join11(dest, rel), { force: true });
4263
+ rmSync2(join12(dest, rel), { force: true });
4042
4264
  removed++;
4043
4265
  }
4044
4266
  return { restored, removed };
@@ -4064,13 +4286,39 @@ const timer = setTimeout(() => finish({ timedOut: true }), Number(budgetMs));
4064
4286
  child.on('error', (e) => { clearTimeout(timer); finish({ error: String(e) }); });
4065
4287
  child.on('exit', (code, signal) => { clearTimeout(timer); finish({ exit: code, signal }); });
4066
4288
  `;
4289
+ var DROPPED_ENV = /* @__PURE__ */ new Set([
4290
+ "NODE_OPTIONS",
4291
+ "NODE_PATH",
4292
+ "NODE_REPL_EXTERNAL_MODULE",
4293
+ "PYTHONPATH",
4294
+ "PYTHONSTARTUP",
4295
+ "PYTEST_ADDOPTS",
4296
+ "PYTEST_PLUGINS"
4297
+ ]);
4298
+ var PINNED_NPM = /^npm_config_(node_options|userconfig|globalconfig)$/i;
4299
+ function suiteEnv(scratch) {
4300
+ const env = {};
4301
+ for (const [k, v] of Object.entries(process.env)) {
4302
+ if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4303
+ env[k] = v;
4304
+ }
4305
+ const userRc = join12(scratch, "npmrc-user");
4306
+ const globalRc = join12(scratch, "npmrc-global");
4307
+ for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4308
+ env.npm_config_userconfig = userRc;
4309
+ env.npm_config_globalconfig = globalRc;
4310
+ env.npm_config_node_options = " ";
4311
+ env.YARN_IGNORE_PATH = "1";
4312
+ return env;
4313
+ }
4067
4314
  function runSuite(dir, cmd, budgetSecs) {
4068
4315
  const t0 = Date.now();
4069
- const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
4070
- const outFile = join11(outDir, "result.json");
4316
+ const outDir = mkdtempSync2(join12(tmpdir2(), "tw-verify-run-"));
4317
+ const outFile = join12(outDir, "result.json");
4071
4318
  try {
4072
4319
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
4073
4320
  cwd: dir,
4321
+ env: suiteEnv(outDir),
4074
4322
  stdio: "ignore",
4075
4323
  timeout: budgetSecs * 1e3 + 3e4,
4076
4324
  // backstop only; the supervisor enforces the budget
@@ -4093,6 +4341,10 @@ function runSuite(dir, cmd, budgetSecs) {
4093
4341
  function runVerify(opts) {
4094
4342
  const cwd = opts.cwd ?? process.cwd();
4095
4343
  const out2 = (s) => void process.stdout.write(s + "\n");
4344
+ if (opts.invalid) {
4345
+ out2(`verify: ${opts.invalid} \u2014 failing closed`);
4346
+ return 2;
4347
+ }
4096
4348
  let policy;
4097
4349
  try {
4098
4350
  if (opts.policyOverride) policy = opts.policyOverride;
@@ -4126,8 +4378,8 @@ function runVerify(opts) {
4126
4378
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
4127
4379
  return 2;
4128
4380
  }
4129
- const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
4130
- const visDir = join11(visRoot, "t");
4381
+ const visRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-vis-"));
4382
+ const visDir = join12(visRoot, "t");
4131
4383
  const cleanup = (dirs) => {
4132
4384
  if (opts.keep) return;
4133
4385
  for (const d of dirs) rmSync2(d, { recursive: true, force: true });
@@ -4155,8 +4407,8 @@ function runVerify(opts) {
4155
4407
  out2("pristine run executes through it too. Failing closed, not open.");
4156
4408
  return 2;
4157
4409
  }
4158
- const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
4159
- const priDir = join11(priRoot, "t");
4410
+ const priRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-pri-"));
4411
+ const priDir = join12(priRoot, "t");
4160
4412
  let restored = [];
4161
4413
  let removedAdded = 0;
4162
4414
  try {
@@ -4236,15 +4488,32 @@ function runVerify(opts) {
4236
4488
  }
4237
4489
  function parseVerify(args) {
4238
4490
  const o = {};
4491
+ const value = (i, flag) => {
4492
+ const v = args[i + 1];
4493
+ if (v === void 0 || v.startsWith("--")) {
4494
+ o.invalid ??= `${flag} needs a value` + (v === void 0 ? "" : ` (got the flag "${v}")`);
4495
+ return void 0;
4496
+ }
4497
+ return v;
4498
+ };
4239
4499
  for (let i = 0; i < args.length; i++) {
4240
4500
  const a = args[i];
4241
- if (a === "--base" && args[i + 1]) o.base = args[++i];
4242
- else if (a === "--cmd" && args[i + 1]) o.cmd = args[++i];
4243
- else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
4244
- else if (a === "--json") o.json = true;
4501
+ if (a === "--json") o.json = true;
4245
4502
  else if (a === "--keep") o.keep = true;
4246
4503
  else if (a === "--require-ancestor") o.requireAncestor = true;
4247
- else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
4504
+ else if (a === "--base" || a === "--cmd" || a === "--cwd" || a === "--budget") {
4505
+ const v = value(i, a);
4506
+ if (v === void 0) continue;
4507
+ i++;
4508
+ if (a === "--base") o.base = v;
4509
+ else if (a === "--cmd") o.cmd = v;
4510
+ else if (a === "--cwd") o.cwd = v;
4511
+ else {
4512
+ o.budget = Number(v);
4513
+ if (!Number.isFinite(o.budget) || o.budget <= 0)
4514
+ o.invalid ??= `--budget needs a positive number of seconds (got "${v}")`;
4515
+ }
4516
+ }
4248
4517
  }
4249
4518
  return o;
4250
4519
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.2.1",
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",