tamperward 2.10.3 → 2.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/git/build.ts
4
- import { execFileSync } from "node:child_process";
4
+ import { execFileSync as execFileSync2 } from "node:child_process";
5
5
 
6
6
  // src/disk.ts
7
7
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from "node:fs";
@@ -602,10 +602,62 @@ function addHunks(content) {
602
602
  ];
603
603
  }
604
604
 
605
+ // src/git/trusted.ts
606
+ import { execFileSync } from "node:child_process";
607
+ import { createHash } from "node:crypto";
608
+ import { existsSync, readFileSync } from "node:fs";
609
+ import { isAbsolute, join as join2 } from "node:path";
610
+ function trustedGitEnv() {
611
+ return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
612
+ }
613
+ function gitDirOf(cwd) {
614
+ try {
615
+ const d = execFileSync("git", ["rev-parse", "--git-dir"], {
616
+ cwd,
617
+ encoding: "utf8",
618
+ stdio: ["ignore", "pipe", "pipe"],
619
+ env: trustedGitEnv()
620
+ }).trim();
621
+ if (!d) return null;
622
+ return isAbsolute(d) ? d : join2(cwd, d);
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ function objectRewriteState(cwd) {
628
+ const parts = [];
629
+ try {
630
+ parts.push(
631
+ "replace:" + execFileSync("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
632
+ cwd,
633
+ encoding: "utf8",
634
+ stdio: ["ignore", "pipe", "pipe"],
635
+ env: trustedGitEnv()
636
+ }).trim()
637
+ );
638
+ } catch {
639
+ parts.push("replace:<unreadable>");
640
+ }
641
+ const dir = gitDirOf(cwd);
642
+ for (const rel of ["info/grafts", "shallow"]) {
643
+ if (dir === null) {
644
+ parts.push(`${rel}:<no-git-dir>`);
645
+ continue;
646
+ }
647
+ const p = join2(dir, rel);
648
+ try {
649
+ parts.push(`${rel}:` + (existsSync(p) ? readFileSync(p, "utf8").trim() : ""));
650
+ } catch {
651
+ parts.push(`${rel}:<unreadable>`);
652
+ }
653
+ }
654
+ return createHash("sha256").update(parts.join("\n")).digest("hex");
655
+ }
656
+
605
657
  // src/git/build.ts
606
658
  function git(args, cwd) {
607
659
  try {
608
- return execFileSync("git", args, {
660
+ return execFileSync2("git", args, {
609
661
  cwd,
610
662
  encoding: "utf8",
611
663
  // 256 MiB, matching the hook adapter. Every diff below runs with `--text`, so a
@@ -614,7 +666,12 @@ function git(args, cwd) {
614
666
  // the cap git() throws and the view fails CLOSED (a truncated patch would parse
615
667
  // as a smaller change than the one landing), which is the documented stance.
616
668
  maxBuffer: 256 * 1024 * 1024,
617
- stdio: ["ignore", "pipe", "pipe"]
669
+ stdio: ["ignore", "pipe", "pipe"],
670
+ // Every object read in this shared adapter can feed a trust decision:
671
+ // policy-at-base, merge-base selection, range enrichment, and the HEAD
672
+ // side of worktree/staged comparisons. A pre-existing replace ref must
673
+ // never make those callers see an attacker-selected object.
674
+ env: trustedGitEnv()
618
675
  });
619
676
  } catch (e) {
620
677
  const err2 = e;
@@ -765,6 +822,104 @@ function hiddenTrackedPaths(opts = {}) {
765
822
  return out2;
766
823
  }
767
824
 
825
+ // src/adapters/claude/changes.ts
826
+ import { execFileSync as execFileSync3 } from "node:child_process";
827
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
828
+ import { tmpdir } from "node:os";
829
+ import { isAbsolute as isAbsolute2, join as join3, relative, resolve } from "node:path";
830
+ function asStr(v) {
831
+ return typeof v === "string" ? v : "";
832
+ }
833
+ function readDisk(path) {
834
+ return textOf(inspectResolved(path));
835
+ }
836
+ function abs(path, cwd) {
837
+ return resolve(cwd, path);
838
+ }
839
+ function relForDisplay(path, cwd) {
840
+ const rel = relative(cwd, path);
841
+ return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
842
+ }
843
+ function applyEdit(content, oldStr, newStr) {
844
+ if (content === null) return newStr;
845
+ if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
846
+ return content.replace(oldStr, () => newStr);
847
+ }
848
+ function synthFileChange(displayPath, before, after) {
849
+ if (before === after) return [];
850
+ let op;
851
+ if (before === null) op = "add";
852
+ else if (after === null) op = "delete";
853
+ else op = "modify";
854
+ const dir = mkdtempSync(join3(tmpdir(), "hf-"));
855
+ let raw = "";
856
+ try {
857
+ const a = join3(dir, "a");
858
+ const b = join3(dir, "b");
859
+ writeFileSync(a, before ?? "");
860
+ writeFileSync(b, after ?? "");
861
+ try {
862
+ raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
863
+ encoding: "utf8",
864
+ maxBuffer: 256 * 1024 * 1024
865
+ });
866
+ } catch (e) {
867
+ const err2 = e;
868
+ if (err2.status !== 1) {
869
+ throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
870
+ }
871
+ raw = err2.stdout ? String(err2.stdout) : "";
872
+ }
873
+ } finally {
874
+ rmSync(dir, { recursive: true, force: true });
875
+ }
876
+ const first = parseDiff(raw)[0];
877
+ const hunks = first && first.kind === "file" ? first.hunks : [];
878
+ return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
879
+ }
880
+ function changesFromClaudeHook(input, cwd) {
881
+ const ti = input.tool_input ?? {};
882
+ switch (input.tool_name) {
883
+ case "Bash": {
884
+ const raw = asStr(ti.command);
885
+ return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
886
+ }
887
+ case "Write": {
888
+ const fp = asStr(ti.file_path);
889
+ if (!fp) return [];
890
+ const before = readDisk(abs(fp, cwd));
891
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
892
+ }
893
+ case "Edit": {
894
+ const fp = asStr(ti.file_path);
895
+ if (!fp) return [];
896
+ const before = readDisk(abs(fp, cwd));
897
+ const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
898
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
899
+ }
900
+ case "MultiEdit": {
901
+ const fp = asStr(ti.file_path);
902
+ if (!fp) return [];
903
+ const before = readDisk(abs(fp, cwd));
904
+ let after = before;
905
+ const edits = Array.isArray(ti.edits) ? ti.edits : [];
906
+ for (const raw of edits) {
907
+ const ed = raw;
908
+ after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
909
+ }
910
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
911
+ }
912
+ case "NotebookEdit": {
913
+ const fp = asStr(ti.notebook_path);
914
+ const src = asStr(ti.new_source);
915
+ if (!fp || !src) return [];
916
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
917
+ }
918
+ default:
919
+ return [];
920
+ }
921
+ }
922
+
768
923
  // src/detectors/finding.ts
769
924
  function severityOf(rule, policy, fallback = "block") {
770
925
  return policy.rules?.[rule]?.severity ?? fallback;
@@ -1230,12 +1385,16 @@ var lintSuppression = {
1230
1385
 
1231
1386
  // src/detectors/test-skip.ts
1232
1387
  var RULE3 = "test-skip";
1388
+ var acc = (names) => `(?:\\s*\\.\\s*(?:${names})(?![\\w$])|\\s*\\[\\s*['"\`](?:${names})['"\`]\\s*\\])`;
1389
+ var JS_RUNNER = "\\b(?:it|test|describe|suite)";
1390
+ var JS_MOD = "concurrent|sequential|shuffle";
1233
1391
  var PATTERNS2 = {
1234
1392
  js: [
1235
- // vitest chains a concurrency modifier before the marker: it.concurrent.skip.
1236
- { re: /\b(?:it|test|describe|suite)(?:\.(?:concurrent|sequential|shuffle))?\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
1237
- { re: /\b(?:it|test|describe|suite)\.(?:skipIf|runIf)\s*\(/, why: "a .skipIf()/.runIf() condition (the test runs only when the condition allows)" },
1238
- { re: /\b(?:it|test)\.(?:fails|failing)\s*\(/, why: "a .fails/.failing marker (the test now passes by failing)" },
1393
+ // The skip/only/todo family on a runner, by dot-or-bracket access, with one optional
1394
+ // vitest concurrency modifier hop: test.skip, test . skip, test['skip'], it.concurrent.skip.
1395
+ { re: new RegExp(JS_RUNNER + acc(JS_MOD) + "?" + acc("skip|only|todo")), why: "a .skip/.only/.todo marker" },
1396
+ { re: new RegExp(JS_RUNNER + acc("skipIf|runIf") + "\\s*\\("), why: "a .skipIf()/.runIf() condition (the test runs only when the condition allows)" },
1397
+ { re: new RegExp(JS_RUNNER + acc("fails|failing") + "\\s*\\("), why: "a .fails/.failing marker (the test now passes by failing)" },
1239
1398
  { re: /\b(?:it|test|describe)\.each\s*\(\s*\[\s*\]\s*\)/, why: "an empty .each table (no case ever runs)" },
1240
1399
  { re: /\b(?:xit|xtest|xdescribe)\s*\(/, why: "an x-prefixed disabled test" },
1241
1400
  { re: /\b(?:fit|ftest|fdescribe)\s*\(/, why: "an f-prefixed focused test (narrows the suite)" },
@@ -1249,7 +1408,7 @@ var PATTERNS2 = {
1249
1408
  { re: /\b(?:it|test|describe|suite)\s*\(\s*(['"`])(?:(?!\1).)*\1\s*,\s*\{[^}]*?\bskip:(?!\s*(?:false|0)\b)/, why: "a { skip: ... } option disabling the test (node:test options form)" },
1250
1409
  { re: /\bskip:\s*(?:true|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
1251
1410
  { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
1252
- { re: /\b(?:t|ctx|context|this)\.skip\(/, why: "a runtime t.skip()/this.skip() call" }
1411
+ { re: new RegExp("\\b(?:t|ctx|context|this)" + acc("skip") + "\\s*\\("), why: "a runtime t.skip()/this.skip() call" }
1253
1412
  ],
1254
1413
  py: [
1255
1414
  // `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
@@ -1306,6 +1465,17 @@ var PATTERNS2 = {
1306
1465
  { re: /\bAssert\.Inconclusive\(/, why: "an Assert.Inconclusive() call (the test ends without a verdict)" }
1307
1466
  ]
1308
1467
  };
1468
+ function matchesOutsideString(p, content, lang) {
1469
+ if (p.comment) return p.re.test(content);
1470
+ const re = p.re.global ? p.re : new RegExp(p.re.source, p.re.flags + "g");
1471
+ re.lastIndex = 0;
1472
+ let m;
1473
+ while ((m = re.exec(content)) !== null) {
1474
+ if (!insideStringLiteral(content, m.index, lang)) return true;
1475
+ if (m.index === re.lastIndex) re.lastIndex++;
1476
+ }
1477
+ return false;
1478
+ }
1309
1479
  var testSkip = {
1310
1480
  id: RULE3,
1311
1481
  surface: ["file"],
@@ -1321,7 +1491,7 @@ var testSkip = {
1321
1491
  const comment = isCommentLine(l.content.trim(), lang);
1322
1492
  for (const p of patterns) {
1323
1493
  if (comment && !p.comment) continue;
1324
- if (p.re.test(l.content)) {
1494
+ if (matchesOutsideString(p, l.content, lang)) {
1325
1495
  out2.push(
1326
1496
  makeFinding(RULE3, policy, {
1327
1497
  file: c.path,
@@ -1345,9 +1515,9 @@ import picomatch2 from "picomatch";
1345
1515
  import ts2 from "typescript";
1346
1516
 
1347
1517
  // src/detectors/repo.ts
1348
- import { execFileSync as execFileSync2 } from "node:child_process";
1349
- import { readFileSync } from "node:fs";
1350
- import { join as join2 } from "node:path";
1518
+ import { execFileSync as execFileSync4 } from "node:child_process";
1519
+ import { readFileSync as readFileSync2 } from "node:fs";
1520
+ import { join as join4 } from "node:path";
1351
1521
  var cache = /* @__PURE__ */ new Map();
1352
1522
  function trackedFiles(ctx) {
1353
1523
  if (!ctx) return null;
@@ -1357,7 +1527,7 @@ function trackedFiles(ctx) {
1357
1527
  if (hit !== void 0) return hit;
1358
1528
  let files = null;
1359
1529
  try {
1360
- files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1530
+ files = execFileSync4("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1361
1531
  cwd: ctx.cwd,
1362
1532
  encoding: "utf8",
1363
1533
  maxBuffer: 64 * 1024 * 1024,
@@ -1379,7 +1549,7 @@ function trackedContent(path, ctx) {
1379
1549
  if (hit !== void 0) return hit;
1380
1550
  let src = null;
1381
1551
  try {
1382
- src = readFileSync(join2(ctx.cwd, path), "utf8");
1552
+ src = readFileSync2(join4(ctx.cwd, path), "utf8");
1383
1553
  } catch {
1384
1554
  src = null;
1385
1555
  }
@@ -1394,7 +1564,7 @@ function branchRefs(ctx) {
1394
1564
  if (hit !== void 0) return hit;
1395
1565
  let refs = null;
1396
1566
  try {
1397
- const out2 = execFileSync2("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
1567
+ const out2 = execFileSync4("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
1398
1568
  cwd: ctx.cwd,
1399
1569
  encoding: "utf8",
1400
1570
  stdio: ["ignore", "pipe", "pipe"]
@@ -1417,7 +1587,7 @@ function revIsHead(rev, ctx) {
1417
1587
  if (!ctx?.cwd || rev.startsWith("-")) return null;
1418
1588
  const parse3 = (r) => {
1419
1589
  try {
1420
- return execFileSync2("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1590
+ return execFileSync4("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1421
1591
  cwd: ctx.cwd,
1422
1592
  encoding: "utf8",
1423
1593
  stdio: ["ignore", "pipe", "pipe"]
@@ -1437,7 +1607,7 @@ function defaultBranch(ctx) {
1437
1607
  if (hit !== void 0) return hit;
1438
1608
  let name = null;
1439
1609
  try {
1440
- const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
1610
+ const ref = execFileSync4("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
1441
1611
  cwd: ctx.cwd,
1442
1612
  encoding: "utf8",
1443
1613
  stdio: ["ignore", "pipe", "pipe"]
@@ -2691,20 +2861,20 @@ var ciTampering = {
2691
2861
  };
2692
2862
 
2693
2863
  // src/detectors/hook-tampering.ts
2694
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
2695
- import { isAbsolute as isAbsolute3, join as join4, relative } from "node:path";
2864
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
2865
+ import { isAbsolute as isAbsolute5, join as join6, relative as relative2 } from "node:path";
2696
2866
 
2697
2867
  // src/wiring.ts
2698
- import { readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
2868
+ import { readFileSync as readFileSync3, realpathSync as realpathSync2 } from "node:fs";
2699
2869
  import { homedir } from "node:os";
2700
- import { basename, dirname, isAbsolute, join as join3, posix } from "node:path";
2870
+ import { basename, dirname, isAbsolute as isAbsolute3, join as join5, posix } from "node:path";
2701
2871
  import { fileURLToPath } from "node:url";
2702
2872
  function shippedVersion() {
2703
2873
  try {
2704
2874
  const here = dirname(fileURLToPath(import.meta.url));
2705
2875
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2706
2876
  try {
2707
- const pkg = JSON.parse(readFileSync2(join3(here, rel), "utf8"));
2877
+ const pkg = JSON.parse(readFileSync3(join5(here, rel), "utf8"));
2708
2878
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2709
2879
  } catch {
2710
2880
  }
@@ -2717,10 +2887,12 @@ var TW_VERSION = shippedVersion();
2717
2887
  var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
2718
2888
  var PRE_MATCHER = PRE_TOOLS.join("|");
2719
2889
  var MARKER = "# tamperward: block agent shortcuts before they land";
2720
- var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2721
- var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2722
- var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2723
- var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2890
+ var NPX_AUTHORITY = "npx --yes --global --userconfig=/dev/null --globalconfig=/dev/null/npmrc-global --registry=https://registry.npmjs.org/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online";
2891
+ var AUTHORITY_FAIL_CLOSED = " || (echo tamperward: authority failed to start >&2 && exit 2)";
2892
+ var HOOK_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} hook claude${AUTHORITY_FAIL_CLOSED}`;
2893
+ var SWEEP_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} sweep claude${AUTHORITY_FAIL_CLOSED}`;
2894
+ var PRECOMMIT_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} check --staged`;
2895
+ var OURS = /^\s*npx --yes --global --userconfig=\/dev\/null --globalconfig=\/dev\/null\/npmrc-global --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? (?:(hook claude|sweep claude) \|\| \(echo tamperward: authority failed to start >&2 && exit 2\)|(check --staged))\s*$/;
2724
2896
  var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
2725
2897
  function compareVersions(a, b) {
2726
2898
  if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
@@ -2734,7 +2906,7 @@ function pinNotBelow(now, floor) {
2734
2906
  const c = compareVersions(now, floor);
2735
2907
  return c !== null && c >= 0;
2736
2908
  }
2737
- var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n*$/;
2909
+ var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes --global --userconfig=\/dev\/null --globalconfig=\/dev\/null\/npmrc-global --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? check --staged\n*$/;
2738
2910
  function initScriptPin(src) {
2739
2911
  const m = src.match(INIT_SCRIPT);
2740
2912
  return m ? m[1] ?? "" : null;
@@ -2772,7 +2944,7 @@ function canonicalPath(path) {
2772
2944
  }
2773
2945
  function resolvesToClaudeSettings(path, env = process.env) {
2774
2946
  if (isClaudeSettings(path, env)) return true;
2775
- return isAbsolute(path) && isClaudeSettings(canonicalPath(path), env);
2947
+ return isAbsolute3(path) && isClaudeSettings(canonicalPath(path), env);
2776
2948
  }
2777
2949
 
2778
2950
  // src/detectors/policy-diff.ts
@@ -2900,8 +3072,8 @@ function policyAddWeakening(after) {
2900
3072
  }
2901
3073
 
2902
3074
  // src/detectors/hook-wiring.ts
2903
- import { existsSync } from "node:fs";
2904
- import { isAbsolute as isAbsolute2, resolve } from "node:path";
3075
+ import { existsSync as existsSync2 } from "node:fs";
3076
+ import { isAbsolute as isAbsolute4, resolve as resolve2 } from "node:path";
2905
3077
  import { parse as parseYaml } from "yaml";
2906
3078
  function stripComment(line) {
2907
3079
  let single = false;
@@ -4015,16 +4187,16 @@ function expandShellPath(t, env = process.env) {
4015
4187
  }
4016
4188
  function hookTests(policy, ctx) {
4017
4189
  const cwd = ctx?.cwd;
4018
- const onDisk = cwd !== void 0 && existsSync(cwd);
4190
+ const onDisk = cwd !== void 0 && existsSync2(cwd);
4019
4191
  const root = onDisk ? canonicalPath(cwd) : cwd;
4020
4192
  const memo = /* @__PURE__ */ new Map();
4021
4193
  const rel = (t) => {
4022
4194
  const hit = memo.get(t);
4023
4195
  if (hit !== void 0) return hit;
4024
4196
  let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
4025
- const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
4026
- if (pathy && (isAbsolute2(p) || onDisk)) {
4027
- p = canonicalPath(isAbsolute2(p) ? p : resolve(cwd, p));
4197
+ const pathy = isAbsolute4(p) || p.includes("/") && !/^[-:]/.test(p);
4198
+ if (pathy && (isAbsolute4(p) || onDisk)) {
4199
+ p = canonicalPath(isAbsolute4(p) ? p : resolve2(cwd, p));
4028
4200
  if (root !== void 0) {
4029
4201
  if (p === root) p = ".";
4030
4202
  else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
@@ -4211,7 +4383,6 @@ var declares = (s, event) => eventEntries(s, event) !== null;
4211
4383
  var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
4212
4384
  var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
4213
4385
  var SIBLING_KEYS = GATE_KEYS;
4214
- var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
4215
4386
  var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
4216
4387
  var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
4217
4388
  var ENV_MECHANISM = [
@@ -4229,10 +4400,11 @@ function analyseGateCommand(cmd, event) {
4229
4400
  const want = event === "PreToolUse" ? "hook" : "sweep";
4230
4401
  const problems = [];
4231
4402
  let runs = true;
4232
- const canonical2 = cmd.match(CANONICAL_CMD);
4403
+ const canonical2 = cmd.match(OURS);
4233
4404
  if (canonical2) {
4234
- if (canonical2[2] !== want) {
4235
- problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
4405
+ const kind = canonical2[2];
4406
+ if (kind !== `${want} claude`) {
4407
+ problems.push(`runs \`${kind}\` under ${event}`);
4236
4408
  runs = false;
4237
4409
  }
4238
4410
  if (!PLAIN_SEMVER.test(canonical2[1])) {
@@ -4582,8 +4754,8 @@ function lefthookBase(c, changes, ctx) {
4582
4754
  if (!ctx?.cwd) return null;
4583
4755
  for (const n of names) {
4584
4756
  try {
4585
- const p = join4(ctx.cwd, n);
4586
- if (existsSync2(p)) return parseDoc(readFileSync3(p, "utf8"));
4757
+ const p = join6(ctx.cwd, n);
4758
+ if (existsSync3(p)) return parseDoc(readFileSync4(p, "utf8"));
4587
4759
  } catch {
4588
4760
  }
4589
4761
  }
@@ -4653,9 +4825,9 @@ function updateIndexHiding(toks, policy) {
4653
4825
  return null;
4654
4826
  }
4655
4827
  function repoRelative(path, ctx) {
4656
- if (!ctx?.cwd || !isAbsolute3(path)) return path;
4657
- const rel = relative(ctx.cwd, path);
4658
- return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
4828
+ if (!ctx?.cwd || !isAbsolute5(path)) return path;
4829
+ const rel = relative2(ctx.cwd, path);
4830
+ return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
4659
4831
  }
4660
4832
  var HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"';
4661
4833
  var HUSKY_SHIM_NAMES = /* @__PURE__ */ new Set(["applypatch-msg", "commit-msg", "post-applypatch", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-applypatch", "pre-auto-gc", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg"]);
@@ -4704,7 +4876,7 @@ function huskyRuntimeWrite(path, content, ctx) {
4704
4876
  if (!ok) return false;
4705
4877
  if (!ctx?.cwd) return false;
4706
4878
  const root = path.slice(0, path.length - m[0].length + (m[0].startsWith("/") ? 1 : 0));
4707
- const entry = inspectRel(join4(ctx.cwd, root), ".husky/pre-commit");
4879
+ const entry = inspectRel(join6(ctx.cwd, root), ".husky/pre-commit");
4708
4880
  if (entry.kind !== "file") return false;
4709
4881
  const gate = textOf(entry);
4710
4882
  if (gate == null) return false;
@@ -4957,7 +5129,7 @@ var hookTampering = {
4957
5129
  if (why) {
4958
5130
  const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
4959
5131
  const file = target ? repoRelative(target, ctx) : void 0;
4960
- const outside = file !== void 0 && (isAbsolute3(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
5132
+ const outside = file !== void 0 && (isAbsolute5(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
4961
5133
  out2.push(
4962
5134
  makeFinding(RULE6, policy, {
4963
5135
  ...file !== void 0 ? { file } : {},
@@ -6084,8 +6256,8 @@ function hasBlocking(findings) {
6084
6256
  }
6085
6257
 
6086
6258
  // src/policy-load.ts
6087
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
6088
- import { isAbsolute as isAbsolute4, join as join5, posix as posix2 } from "node:path";
6259
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "node:fs";
6260
+ import { isAbsolute as isAbsolute6, join as join7, posix as posix2 } from "node:path";
6089
6261
  import { parse as parse2 } from "yaml";
6090
6262
  var PolicyError = class extends Error {
6091
6263
  };
@@ -6102,7 +6274,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
6102
6274
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6103
6275
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
6104
6276
  function ledgerInsideRepo(ledger) {
6105
- if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
6277
+ if (!ledger.trim() || isAbsolute6(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
6106
6278
  const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
6107
6279
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
6108
6280
  }
@@ -6209,9 +6381,9 @@ function parseOrThrow(src, where) {
6209
6381
  return parsePolicy(raw, where);
6210
6382
  }
6211
6383
  function loadPolicy(cwd = process.cwd()) {
6212
- const path = join5(cwd, POLICY_FILE);
6213
- if (!existsSync3(path)) return defaultPolicy();
6214
- return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
6384
+ const path = join7(cwd, POLICY_FILE);
6385
+ if (!existsSync4(path)) return defaultPolicy();
6386
+ return parseOrThrow(readFileSync5(path, "utf8"), POLICY_FILE);
6215
6387
  }
6216
6388
  function loadPolicyAt(rev, cwd) {
6217
6389
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -6220,12 +6392,12 @@ function loadPolicyAt(rev, cwd) {
6220
6392
  }
6221
6393
 
6222
6394
  // src/signoff.ts
6223
- import { createHash } from "node:crypto";
6224
- import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync4 } from "node:fs";
6225
- import { dirname as dirname2, join as join6 } from "node:path";
6395
+ import { createHash as createHash2 } from "node:crypto";
6396
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "node:fs";
6397
+ import { dirname as dirname2, join as join8 } from "node:path";
6226
6398
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
6227
6399
  function fingerprint(rule, file, evidence) {
6228
- return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
6400
+ return createHash2("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
6229
6401
  }
6230
6402
  var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
6231
6403
  function ledgerPath(cwd, policy) {
@@ -6233,13 +6405,13 @@ function ledgerPath(cwd, policy) {
6233
6405
  if (!ledgerInsideRepo(rel)) {
6234
6406
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
6235
6407
  }
6236
- return join6(cwd, rel);
6408
+ return join8(cwd, rel);
6237
6409
  }
6238
6410
  function readLedger(cwd, policy) {
6239
6411
  const p = ledgerPath(cwd, policy);
6240
- if (!existsSync4(p)) return [];
6412
+ if (!existsSync5(p)) return [];
6241
6413
  const out2 = [];
6242
- for (const line of readFileSync5(p, "utf8").split("\n")) {
6414
+ for (const line of readFileSync6(p, "utf8").split("\n")) {
6243
6415
  if (!line.trim()) continue;
6244
6416
  try {
6245
6417
  const e = JSON.parse(line);
@@ -6279,7 +6451,9 @@ function oobToken(want, oob, head) {
6279
6451
  }
6280
6452
  const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
6281
6453
  if (rule !== want) continue;
6282
- if (!head || sha2.length >= 7 && head.startsWith(sha2)) return t;
6454
+ const normalizedHead = head?.trim().toLowerCase();
6455
+ const normalizedSha = sha2.trim().toLowerCase();
6456
+ if (!head || /^[0-9a-f]+$/.test(normalizedHead) && normalizedSha === normalizedHead) return t;
6283
6457
  }
6284
6458
  return null;
6285
6459
  }
@@ -6295,8 +6469,8 @@ function applyOobSignoffs(findings, oob, head) {
6295
6469
  return { findings: remaining, cleared };
6296
6470
  }
6297
6471
  function oobHeadFromEnv(env = process.env) {
6298
- const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
6299
- return v.length >= 7 ? v : void 0;
6472
+ const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim().toLowerCase();
6473
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(v) ? v : void 0;
6300
6474
  }
6301
6475
  function oobFromEnv(env = process.env) {
6302
6476
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
@@ -6558,6 +6732,11 @@ function check(opts) {
6558
6732
  } else if (opts.worktree) {
6559
6733
  const p = policy;
6560
6734
  changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
6735
+ const seen = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
6736
+ for (const rel of hiddenTrackedPaths({ cwd: opts.cwd })) {
6737
+ if (seen.has(rel) || !isProtected(rel, p)) continue;
6738
+ changes.push(...synthFileChange(rel, fileAt("HEAD", rel, { cwd: opts.cwd }), fileOnDisk(rel, { cwd: opts.cwd })));
6739
+ }
6561
6740
  layer = opts.ciLayer ? "ci" : "local";
6562
6741
  view = "worktree";
6563
6742
  } else if (opts.diff) {
@@ -6609,106 +6788,8 @@ function runCheck(opts) {
6609
6788
  }
6610
6789
 
6611
6790
  // src/cli/hook.ts
6612
- import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
6613
- import { createHash as createHash4 } from "node:crypto";
6614
-
6615
- // src/adapters/claude/changes.ts
6616
- import { execFileSync as execFileSync3 } from "node:child_process";
6617
- import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
6618
- import { tmpdir } from "node:os";
6619
- import { isAbsolute as isAbsolute5, join as join7, relative as relative2, resolve as resolve2 } from "node:path";
6620
- function asStr(v) {
6621
- return typeof v === "string" ? v : "";
6622
- }
6623
- function readDisk(path) {
6624
- return textOf(inspectResolved(path));
6625
- }
6626
- function abs(path, cwd) {
6627
- return resolve2(cwd, path);
6628
- }
6629
- function relForDisplay(path, cwd) {
6630
- const rel = relative2(cwd, path);
6631
- return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
6632
- }
6633
- function applyEdit(content, oldStr, newStr) {
6634
- if (content === null) return newStr;
6635
- if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
6636
- return content.replace(oldStr, () => newStr);
6637
- }
6638
- function synthFileChange(displayPath, before, after) {
6639
- if (before === after) return [];
6640
- let op;
6641
- if (before === null) op = "add";
6642
- else if (after === null) op = "delete";
6643
- else op = "modify";
6644
- const dir = mkdtempSync(join7(tmpdir(), "hf-"));
6645
- let raw = "";
6646
- try {
6647
- const a = join7(dir, "a");
6648
- const b = join7(dir, "b");
6649
- writeFileSync(a, before ?? "");
6650
- writeFileSync(b, after ?? "");
6651
- try {
6652
- raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
6653
- encoding: "utf8",
6654
- maxBuffer: 256 * 1024 * 1024
6655
- });
6656
- } catch (e) {
6657
- const err2 = e;
6658
- if (err2.status !== 1) {
6659
- throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
6660
- }
6661
- raw = err2.stdout ? String(err2.stdout) : "";
6662
- }
6663
- } finally {
6664
- rmSync(dir, { recursive: true, force: true });
6665
- }
6666
- const first = parseDiff(raw)[0];
6667
- const hunks = first && first.kind === "file" ? first.hunks : [];
6668
- return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
6669
- }
6670
- function changesFromClaudeHook(input, cwd) {
6671
- const ti = input.tool_input ?? {};
6672
- switch (input.tool_name) {
6673
- case "Bash": {
6674
- const raw = asStr(ti.command);
6675
- return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
6676
- }
6677
- case "Write": {
6678
- const fp = asStr(ti.file_path);
6679
- if (!fp) return [];
6680
- const before = readDisk(abs(fp, cwd));
6681
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
6682
- }
6683
- case "Edit": {
6684
- const fp = asStr(ti.file_path);
6685
- if (!fp) return [];
6686
- const before = readDisk(abs(fp, cwd));
6687
- const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
6688
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
6689
- }
6690
- case "MultiEdit": {
6691
- const fp = asStr(ti.file_path);
6692
- if (!fp) return [];
6693
- const before = readDisk(abs(fp, cwd));
6694
- let after = before;
6695
- const edits = Array.isArray(ti.edits) ? ti.edits : [];
6696
- for (const raw of edits) {
6697
- const ed = raw;
6698
- after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
6699
- }
6700
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
6701
- }
6702
- case "NotebookEdit": {
6703
- const fp = asStr(ti.notebook_path);
6704
- const src = asStr(ti.new_source);
6705
- if (!fp || !src) return [];
6706
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
6707
- }
6708
- default:
6709
- return [];
6710
- }
6711
- }
6791
+ import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync8 } from "node:fs";
6792
+ import { createHash as createHash5 } from "node:crypto";
6712
6793
 
6713
6794
  // src/adapters/claude/deny.ts
6714
6795
  function formatDenial(blocks) {
@@ -6730,22 +6811,22 @@ function formatDenial(blocks) {
6730
6811
  }
6731
6812
 
6732
6813
  // src/session.ts
6733
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
6734
- import { dirname as dirname3, join as join8 } from "node:path";
6814
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
6815
+ import { dirname as dirname3, join as join9 } from "node:path";
6735
6816
  var UNSAFE = /[^A-Za-z0-9_-]/g;
6736
6817
  var SHA = /^[0-9a-f]{7,40}$/;
6737
6818
  function baselinePath(cwd, sessionId) {
6738
6819
  const gd = gitDir(cwd);
6739
6820
  if (!gd) return null;
6740
- return join8(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
6821
+ return join9(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
6741
6822
  }
6742
6823
  function turnBaseline(cwd, sessionId) {
6743
6824
  if (!sessionId) return null;
6744
6825
  try {
6745
6826
  const p = baselinePath(cwd, sessionId);
6746
6827
  if (!p) return null;
6747
- if (existsSync5(p)) {
6748
- const v = readFileSync6(p, "utf8").trim();
6828
+ if (existsSync6(p)) {
6829
+ const v = readFileSync7(p, "utf8").trim();
6749
6830
  if (SHA.test(v)) return v;
6750
6831
  }
6751
6832
  const head = headSha(cwd);
@@ -6770,21 +6851,21 @@ function advanceTurnBaseline(cwd, sessionId) {
6770
6851
  }
6771
6852
 
6772
6853
  // src/effect.ts
6773
- import { createHash as createHash2 } from "node:crypto";
6774
- import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6775
- import { isAbsolute as isAbsolute6, join as join9 } from "node:path";
6776
- import { execFileSync as execFileSync4 } from "node:child_process";
6854
+ import { createHash as createHash3 } from "node:crypto";
6855
+ import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6856
+ import { isAbsolute as isAbsolute7, join as join10 } from "node:path";
6857
+ import { execFileSync as execFileSync5 } from "node:child_process";
6777
6858
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
6778
6859
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
6779
6860
  function gitDir2(cwd) {
6780
6861
  try {
6781
- const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
6862
+ const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
6782
6863
  cwd,
6783
6864
  encoding: "utf8",
6784
6865
  stdio: ["ignore", "pipe", "pipe"]
6785
6866
  }).trim();
6786
6867
  if (!d) return null;
6787
- return isAbsolute6(d) ? d : join9(cwd, d);
6868
+ return isAbsolute7(d) ? d : join10(cwd, d);
6788
6869
  } catch {
6789
6870
  return null;
6790
6871
  }
@@ -6793,8 +6874,8 @@ function statePath(cwd, sessionId, kind) {
6793
6874
  if (!sessionId) return null;
6794
6875
  const gd = gitDir2(cwd);
6795
6876
  if (!gd) return null;
6796
- const dir = join9(gd, "tamperward");
6797
- return join9(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
6877
+ const dir = join10(gd, "tamperward");
6878
+ return join10(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
6798
6879
  }
6799
6880
  function ptreePath(cwd, sessionId) {
6800
6881
  return statePath(cwd, sessionId, "ptree");
@@ -6802,7 +6883,7 @@ function ptreePath(cwd, sessionId) {
6802
6883
  function turnTreePath(cwd, sessionId) {
6803
6884
  return statePath(cwd, sessionId, "turntree");
6804
6885
  }
6805
- var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
6886
+ var sha = (buf) => createHash3("sha256").update(buf).digest("hex").slice(0, 16);
6806
6887
  function contentHash(content) {
6807
6888
  return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
6808
6889
  }
@@ -6823,7 +6904,7 @@ function snapshotProtected(cwd, policy, prev) {
6823
6904
  } catch {
6824
6905
  }
6825
6906
  const walk2 = (rel) => {
6826
- const abs2 = rel ? join9(cwd, rel) : cwd;
6907
+ const abs2 = rel ? join10(cwd, rel) : cwd;
6827
6908
  let names;
6828
6909
  try {
6829
6910
  names = readdirSync(abs2);
@@ -6836,7 +6917,7 @@ function snapshotProtected(cwd, policy, prev) {
6836
6917
  if (pruned.has(r)) continue;
6837
6918
  let st;
6838
6919
  try {
6839
- st = lstatSync2(join9(cwd, r));
6920
+ st = lstatSync2(join10(cwd, r));
6840
6921
  } catch {
6841
6922
  continue;
6842
6923
  }
@@ -6863,9 +6944,9 @@ function driftBetween(expected, current) {
6863
6944
  return { changed, deleted, added };
6864
6945
  }
6865
6946
  function loadTree(p) {
6866
- if (!p || !existsSync6(p)) return null;
6947
+ if (!p || !existsSync7(p)) return null;
6867
6948
  try {
6868
- return JSON.parse(readFileSync7(p, "utf8"));
6949
+ return JSON.parse(readFileSync8(p, "utf8"));
6869
6950
  } catch {
6870
6951
  return null;
6871
6952
  }
@@ -6873,7 +6954,7 @@ function loadTree(p) {
6873
6954
  function saveTree(p, tree) {
6874
6955
  if (!p) return;
6875
6956
  try {
6876
- mkdirSync3(join9(p, ".."), { recursive: true });
6957
+ mkdirSync3(join10(p, ".."), { recursive: true });
6877
6958
  const tmp = `${p}.${process.pid}.tmp`;
6878
6959
  writeFileSync3(tmp, JSON.stringify(tree));
6879
6960
  renameSync(tmp, p);
@@ -6905,19 +6986,19 @@ function dropTurnTree(cwd, sessionId) {
6905
6986
 
6906
6987
  // src/cli/watch.ts
6907
6988
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
6908
- import { join as join10 } from "node:path";
6909
- import { createHash as createHash3 } from "node:crypto";
6989
+ import { join as join11 } from "node:path";
6990
+ import { createHash as createHash4 } from "node:crypto";
6910
6991
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
6911
6992
  function snap(abs2) {
6912
6993
  const e = inspectPath(abs2);
6913
6994
  if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
6914
6995
  if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
6915
- const hash = createHash3("sha256").update(e.content).digest("hex").slice(0, 16);
6996
+ const hash = createHash4("sha256").update(e.content).digest("hex").slice(0, 16);
6916
6997
  return { mode: e.mode, size: e.size, hash };
6917
6998
  }
6918
6999
  function defaultEventLog(cwd) {
6919
7000
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
6920
- return join10(gitDir(cwd) ?? join10(cwd, ".git"), "tamperward", "fsevents.jsonl");
7001
+ return join11(gitDir(cwd) ?? join11(cwd, ".git"), "tamperward", "fsevents.jsonl");
6921
7002
  }
6922
7003
  function watchTree(dir, cb) {
6923
7004
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -6934,12 +7015,12 @@ function watchTree(dir, cb) {
6934
7015
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
6935
7016
  let w;
6936
7017
  try {
6937
- w = watch(rel ? join10(dir, rel) : dir, (kind, fname) => {
7018
+ w = watch(rel ? join11(dir, rel) : dir, (kind, fname) => {
6938
7019
  if (!fname) return;
6939
7020
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
6940
7021
  cb(kind, child);
6941
7022
  try {
6942
- if (statSync(join10(dir, child)).isDirectory()) addDir(child);
7023
+ if (statSync(join11(dir, child)).isDirectory()) addDir(child);
6943
7024
  } catch {
6944
7025
  }
6945
7026
  });
@@ -6952,7 +7033,7 @@ function watchTree(dir, cb) {
6952
7033
  addDir(rel);
6953
7034
  let names = [];
6954
7035
  try {
6955
- names = readdirSync2(rel ? join10(dir, rel) : dir);
7036
+ names = readdirSync2(rel ? join11(dir, rel) : dir);
6956
7037
  } catch {
6957
7038
  return;
6958
7039
  }
@@ -6960,7 +7041,7 @@ function watchTree(dir, cb) {
6960
7041
  const child = rel ? `${rel}/${name}` : name;
6961
7042
  if (SKIP2.test(child + "/")) continue;
6962
7043
  try {
6963
- if (statSync(join10(dir, child)).isDirectory()) walk2(child);
7044
+ if (statSync(join11(dir, child)).isDirectory()) walk2(child);
6964
7045
  } catch {
6965
7046
  }
6966
7047
  }
@@ -6972,13 +7053,13 @@ function watchTree(dir, cb) {
6972
7053
  }
6973
7054
  function startWatcher(dir, log, policy) {
6974
7055
  try {
6975
- mkdirSync4(join10(log, ".."), { recursive: true });
7056
+ mkdirSync4(join11(log, ".."), { recursive: true });
6976
7057
  } catch {
6977
7058
  }
6978
7059
  const last = /* @__PURE__ */ new Map();
6979
7060
  return watchTree(dir, (kind, rel) => {
6980
7061
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
6981
- const s = snap(join10(dir, rel));
7062
+ const s = snap(join11(dir, rel));
6982
7063
  const key2 = `${s.hash}:${s.mode}`;
6983
7064
  if (last.get(rel) === key2) return;
6984
7065
  last.set(rel, key2);
@@ -7005,7 +7086,7 @@ function runWatch(args) {
7005
7086
  }
7006
7087
 
7007
7088
  // src/detectors/fs-events.ts
7008
- import { readFileSync as readFileSync9, statSync as statSync2 } from "node:fs";
7089
+ import { readFileSync as readFileSync10, statSync as statSync2 } from "node:fs";
7009
7090
  var RULE11 = "transient-protected-mutation";
7010
7091
  function readEvents(log, offset) {
7011
7092
  let size = 0;
@@ -7015,7 +7096,7 @@ function readEvents(log, offset) {
7015
7096
  return { events: [], newOffset: offset };
7016
7097
  }
7017
7098
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
7018
- const raw = readFileSync9(log, "utf8").slice(offset);
7099
+ const raw = readFileSync10(log, "utf8").slice(offset);
7019
7100
  const events = [];
7020
7101
  for (const line of raw.split("\n")) {
7021
7102
  if (!line.trim()) continue;
@@ -7068,7 +7149,7 @@ var HookInputError = class extends Error {
7068
7149
  };
7069
7150
  function readStdin() {
7070
7151
  try {
7071
- return readFileSync10(0, "utf8");
7152
+ return readFileSync11(0, "utf8");
7072
7153
  } catch (e) {
7073
7154
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
7074
7155
  }
@@ -7284,7 +7365,7 @@ function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
7284
7365
  let dirty = false;
7285
7366
  for (const c of changes) {
7286
7367
  if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
7287
- const hash = createHash4("sha256").update(c.after).digest("hex").slice(0, 16);
7368
+ const hash = createHash5("sha256").update(c.after).digest("hex").slice(0, 16);
7288
7369
  const prev = tree[c.path];
7289
7370
  tree[c.path] = {
7290
7371
  hash,
@@ -7321,10 +7402,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7321
7402
  } };
7322
7403
  const log = defaultEventLog(cwd);
7323
7404
  const cp = cursorPath(cwd, sessionId);
7324
- if (!existsSync7(log)) return none;
7405
+ if (!existsSync8(log)) return none;
7325
7406
  let offset = 0;
7326
- if (cp && existsSync7(cp)) {
7327
- const n = Number(readFileSync10(cp, "utf8"));
7407
+ if (cp && existsSync8(cp)) {
7408
+ const n = Number(readFileSync11(cp, "utf8"));
7328
7409
  offset = Number.isFinite(n) ? n : 0;
7329
7410
  }
7330
7411
  const { events, newOffset } = readEvents(log, offset);
@@ -7475,15 +7556,15 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
7475
7556
  }
7476
7557
 
7477
7558
  // src/cli/init.ts
7478
- import { execFileSync as execFileSync5 } from "node:child_process";
7479
- import { createHash as createHash5 } from "node:crypto";
7480
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7481
- import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute7, join as join11, relative as relative3, resolve as resolve3 } from "node:path";
7559
+ import { execFileSync as execFileSync6 } from "node:child_process";
7560
+ import { createHash as createHash6 } from "node:crypto";
7561
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7562
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
7482
7563
  var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
7483
7564
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7484
7565
  function gitConfig(cwd, key2) {
7485
7566
  try {
7486
- return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
7567
+ return execFileSync6("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
7487
7568
  } catch {
7488
7569
  return null;
7489
7570
  }
@@ -7491,6 +7572,8 @@ function gitConfig(cwd, key2) {
7491
7572
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
7492
7573
  var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
7493
7574
  var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
7575
+ var LEGACY_OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
7576
+ var PRE_GLOBAL_OURS = /^\s*npx --yes --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? (hook claude|sweep claude|check --staged)\s*$/;
7494
7577
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
7495
7578
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
7496
7579
  function coveredBy(existing, critical) {
@@ -7535,12 +7618,16 @@ version: 1
7535
7618
  # ignore: [] # visible blind spots \u2014 the count is always reported
7536
7619
  `;
7537
7620
  function pinOf(command) {
7538
- const m = command.match(OURS);
7621
+ const m = ours(command);
7539
7622
  return m ? m[1] ?? "" : null;
7540
7623
  }
7624
+ function ours(command) {
7625
+ const previous = command.replace(" --global", "").replace(" || (echo tamperward: authority failed to start >&2 && exit 2)", "");
7626
+ return command.match(OURS) ?? previous.match(PRE_GLOBAL_OURS) ?? command.match(LEGACY_OURS);
7627
+ }
7541
7628
  function stalePin(command) {
7542
7629
  const pin = pinOf(command);
7543
- return pin !== null && pin !== TW_VERSION;
7630
+ return pin !== null && (pin !== TW_VERSION || !OURS.test(command));
7544
7631
  }
7545
7632
  var WORKFLOW_CONTENT = `name: tamperward
7546
7633
 
@@ -7564,22 +7651,20 @@ jobs:
7564
7651
  tamperward:
7565
7652
  runs-on: ubuntu-latest
7566
7653
  timeout-minutes: 10
7567
- # The gate is fetched from the registry AT GATE TIME, from inside the
7568
- # checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
7569
- # directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
7570
- # the authority judging it is downloaded from; the version pin below pins the
7571
- # STRING, not the SOURCE. An environment variable outranks a project
7572
- # \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
7573
- # something.
7574
- env:
7575
- NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
7576
7654
  steps:
7577
- - uses: actions/checkout@v5
7578
- with:
7579
- fetch-depth: 0 # the range diff needs both endpoints
7580
7655
  - uses: actions/setup-node@v6
7581
7656
  with:
7582
7657
  node-version: 22
7658
+ # Install the authority before candidate files exist in the job workspace.
7659
+ # npm therefore cannot discover a pull-request .npmrc, and every security-
7660
+ # relevant npm setting is fixed on the command line. Later steps invoke the
7661
+ # installed binary directly: npm/npx never starts from candidate-controlled cwd.
7662
+ - name: Install Tamperward authority
7663
+ working-directory: \${{ runner.temp }}
7664
+ run: npm install --global --registry=https://registry.npmjs.org/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward@${TW_VERSION}
7665
+ - uses: actions/checkout@v5
7666
+ with:
7667
+ fetch-depth: 0 # the range diff needs both endpoints
7583
7668
  - name: Resolve out-of-band sign-off from PR labels
7584
7669
  id: oob
7585
7670
  env:
@@ -7594,7 +7679,7 @@ jobs:
7594
7679
  # across pushes, so an unbound one would clear every later finding on
7595
7680
  # the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
7596
7681
  TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
7597
- run: npx --yes tamperward@${TW_VERSION} check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
7682
+ run: tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
7598
7683
  # Diff-time detection is spelling-dependent by nature; pristine
7599
7684
  # re-execution is not, and it was the layer nothing got past in the
7600
7685
  # taskbench sweeps. Shipping a CI template WITHOUT it meant every
@@ -7612,12 +7697,12 @@ jobs:
7612
7697
  # stays red whatever the labels say.
7613
7698
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
7614
7699
  TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
7615
- run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
7700
+ run: tamperward verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
7616
7701
  `;
7617
7702
  var WORKFLOW_MARK = "# tamperward:generated";
7618
7703
  var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
7619
7704
  function bodyHash(body) {
7620
- return createHash5("sha256").update(body).digest("hex").slice(0, 16);
7705
+ return createHash6("sha256").update(body).digest("hex").slice(0, 16);
7621
7706
  }
7622
7707
  function workflowFile(body) {
7623
7708
  return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
@@ -7654,8 +7739,8 @@ function hooksShapeError(hooks) {
7654
7739
  return null;
7655
7740
  }
7656
7741
  function planPolicy(cwd) {
7657
- const path = join11(cwd, POLICY_FILE);
7658
- if (existsSync8(path)) {
7742
+ const path = join12(cwd, POLICY_FILE);
7743
+ if (existsSync9(path)) {
7659
7744
  try {
7660
7745
  loadPolicy(cwd);
7661
7746
  } catch (e) {
@@ -7673,12 +7758,12 @@ function planPolicy(cwd) {
7673
7758
  }
7674
7759
  function planClaudeHooks(cwd) {
7675
7760
  const rel = ".claude/settings.json";
7676
- const path = join11(cwd, rel);
7761
+ const path = join12(cwd, rel);
7677
7762
  let settings = {};
7678
- if (existsSync8(path)) {
7763
+ if (existsSync9(path)) {
7679
7764
  let parsed;
7680
7765
  try {
7681
- parsed = JSON.parse(readFileSync11(path, "utf8"));
7766
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
7682
7767
  } catch {
7683
7768
  return {
7684
7769
  item: "agent",
@@ -7713,7 +7798,7 @@ function planClaudeHooks(cwd) {
7713
7798
  return {
7714
7799
  item: "agent",
7715
7800
  path: rel,
7716
- status: existsSync8(path) ? "update" : "create",
7801
+ status: existsSync9(path) ? "update" : "create",
7717
7802
  detail: [
7718
7803
  needPre && "wire PreToolUse deny",
7719
7804
  needStop && "wire Stop sweep",
@@ -7723,7 +7808,7 @@ function planClaudeHooks(cwd) {
7723
7808
  ].filter(Boolean).join(" + "),
7724
7809
  apply: () => {
7725
7810
  for (const h of repin) {
7726
- const kind = String(h.command ?? "").match(OURS)?.[2];
7811
+ const kind = ours(String(h.command ?? ""))?.[2];
7727
7812
  h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
7728
7813
  }
7729
7814
  if (needPre) {
@@ -7770,15 +7855,15 @@ var MANAGED_HOOKS = [
7770
7855
  ];
7771
7856
  function hooksDir(cwd) {
7772
7857
  try {
7773
- const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7858
+ const out2 = execFileSync6("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7774
7859
  if (out2) return resolve3(cwd, out2);
7775
7860
  } catch {
7776
7861
  }
7777
- return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
7862
+ return existsSync9(join12(cwd, ".git")) ? join12(cwd, ".git", "hooks") : null;
7778
7863
  }
7779
7864
  function display(cwd, path) {
7780
7865
  const rel = relative3(cwd, path);
7781
- return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
7866
+ return rel && !rel.startsWith("..") && !isAbsolute8(rel) ? rel : path;
7782
7867
  }
7783
7868
  function unconditionalExitAt(lines) {
7784
7869
  let depth = 0;
@@ -7801,30 +7886,30 @@ function planPreCommit(cwd) {
7801
7886
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
7802
7887
  let path;
7803
7888
  let note;
7804
- if (existsSync8(join11(cwd, ".husky"))) {
7805
- path = join11(cwd, ".husky", "pre-commit");
7889
+ if (existsSync9(join12(cwd, ".husky"))) {
7890
+ path = join12(cwd, ".husky", "pre-commit");
7806
7891
  note = "husky";
7807
7892
  } else {
7808
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
7893
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync9(join12(cwd, c))));
7809
7894
  if (managed) {
7810
7895
  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}`);
7811
7896
  }
7812
7897
  const dir = hooksDir(cwd);
7813
7898
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
7814
7899
  if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
7815
- path = join11(dirname4(dir), "pre-commit");
7900
+ path = join12(dirname4(dir), "pre-commit");
7816
7901
  note = "husky";
7817
7902
  } else {
7818
- path = join11(dir, "pre-commit");
7903
+ path = join12(dir, "pre-commit");
7819
7904
  const rel2 = display(cwd, dir);
7820
- note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
7905
+ note = rel2 === join12(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
7821
7906
  }
7822
7907
  }
7823
7908
  const rel = display(cwd, path);
7824
- if (existsSync8(path) && !statSync3(path).isFile()) {
7909
+ if (existsSync9(path) && !statSync3(path).isFile()) {
7825
7910
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
7826
7911
  }
7827
- const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
7912
+ const existing = existsSync9(path) ? readFileSync12(path, "utf8") : null;
7828
7913
  if (existing === null) {
7829
7914
  return {
7830
7915
  item: "pre-commit",
@@ -7868,8 +7953,8 @@ ${block.join("\n")}
7868
7953
  };
7869
7954
  }
7870
7955
  if (at !== -1) {
7871
- const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
7872
- if (!ours) {
7956
+ const ours2 = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
7957
+ if (!ours2) {
7873
7958
  return {
7874
7959
  item: "pre-commit",
7875
7960
  path: rel,
@@ -7913,10 +7998,10 @@ ${block.join("\n")}
7913
7998
  function planCodeowners(cwd) {
7914
7999
  const rel = ".github/CODEOWNERS";
7915
8000
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
7916
- (r) => existsSync8(join11(cwd, r))
8001
+ (r) => existsSync9(join12(cwd, r))
7917
8002
  );
7918
- const path = join11(cwd, existingRel ?? rel);
7919
- const existing = existingRel ? readFileSync11(path, "utf8") : null;
8003
+ const path = join12(cwd, existingRel ?? rel);
8004
+ const existing = existingRel ? readFileSync12(path, "utf8") : null;
7920
8005
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
7921
8006
  if (missing.length === 0) {
7922
8007
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -7949,12 +8034,12 @@ ${CODEOWNERS_MARK}
7949
8034
  }
7950
8035
  function planWorkflow(cwd, force) {
7951
8036
  const rel = ".github/workflows/tamperward.yml";
7952
- const path = join11(cwd, rel);
8037
+ const path = join12(cwd, rel);
7953
8038
  const write = () => {
7954
8039
  mkdirSync5(dirname4(path), { recursive: true });
7955
8040
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
7956
8041
  };
7957
- if (!existsSync8(path)) {
8042
+ if (!existsSync9(path)) {
7958
8043
  return {
7959
8044
  item: "ci",
7960
8045
  path: rel,
@@ -7965,7 +8050,7 @@ function planWorkflow(cwd, force) {
7965
8050
  }
7966
8051
  let src;
7967
8052
  try {
7968
- src = readFileSync11(path, "utf8");
8053
+ src = readFileSync12(path, "utf8");
7969
8054
  } catch {
7970
8055
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
7971
8056
  }
@@ -8068,75 +8153,29 @@ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as
8068
8153
  import { tmpdir as tmpdir2 } from "node:os";
8069
8154
  import { dirname as dirname5, join as join14, resolve as resolve4 } from "node:path";
8070
8155
 
8071
- // src/git/trusted.ts
8072
- import { execFileSync as execFileSync6 } from "node:child_process";
8073
- import { createHash as createHash6 } from "node:crypto";
8074
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
8075
- import { isAbsolute as isAbsolute8, join as join12 } from "node:path";
8076
- function trustedGitEnv() {
8077
- return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
8078
- }
8079
- function gitDirOf(cwd) {
8080
- try {
8081
- const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
8082
- cwd,
8083
- encoding: "utf8",
8084
- stdio: ["ignore", "pipe", "pipe"],
8085
- env: trustedGitEnv()
8086
- }).trim();
8087
- if (!d) return null;
8088
- return isAbsolute8(d) ? d : join12(cwd, d);
8089
- } catch {
8090
- return null;
8091
- }
8092
- }
8093
- function objectRewriteState(cwd) {
8094
- const parts = [];
8095
- try {
8096
- parts.push(
8097
- "replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
8098
- cwd,
8099
- encoding: "utf8",
8100
- stdio: ["ignore", "pipe", "pipe"],
8101
- env: trustedGitEnv()
8102
- }).trim()
8103
- );
8104
- } catch {
8105
- parts.push("replace:<unreadable>");
8106
- }
8107
- const dir = gitDirOf(cwd);
8108
- for (const rel of ["info/grafts", "shallow"]) {
8109
- if (dir === null) {
8110
- parts.push(`${rel}:<no-git-dir>`);
8111
- continue;
8112
- }
8113
- const p = join12(dir, rel);
8114
- try {
8115
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
8116
- } catch {
8117
- parts.push(`${rel}:<unreadable>`);
8118
- }
8119
- }
8120
- return createHash6("sha256").update(parts.join("\n")).digest("hex");
8121
- }
8122
-
8123
8156
  // src/fingerprint.ts
8124
8157
  import { execFileSync as execFileSync7 } from "node:child_process";
8125
8158
  import { createHash as createHash7 } from "node:crypto";
8126
8159
  import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
8127
8160
  import { join as join13 } from "node:path";
8128
- function treeFingerprint(cwd) {
8129
- const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
8161
+ function treeFingerprint(cwd, keepIgnored) {
8162
+ const listed = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
8130
8163
  cwd,
8131
8164
  encoding: "utf8",
8132
8165
  maxBuffer: 1 << 28,
8133
8166
  env: trustedGitEnv()
8134
- }).split("\0").filter(Boolean).sort();
8167
+ }).split("\0").filter(Boolean);
8168
+ const files = [.../* @__PURE__ */ new Set([...listed, ...keepIgnored ? ignoredPaths({ cwd }, keepIgnored) : []])].sort();
8135
8169
  const h = createHash7("sha256");
8136
8170
  for (const rel of files) {
8137
8171
  h.update(rel);
8138
8172
  h.update("\0");
8139
- h.update(inspectRel(cwd, rel).content ?? "<unreadable>");
8173
+ const entry = inspectRel(cwd, rel);
8174
+ h.update(entry.kind);
8175
+ h.update("\0");
8176
+ h.update(String(entry.mode));
8177
+ h.update("\0");
8178
+ h.update(entry.content ?? `<${entry.kind}:${entry.detail}>`);
8140
8179
  h.update("\0");
8141
8180
  }
8142
8181
  return h.digest("hex");
@@ -8490,7 +8529,8 @@ function runVerify(opts) {
8490
8529
  if (opts.keep) return;
8491
8530
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
8492
8531
  };
8493
- const treeBefore = treeFingerprint(cwd);
8532
+ const protectedIgnored = (rel) => isProtected(rel, policy);
8533
+ const treeBefore = treeFingerprint(cwd, protectedIgnored);
8494
8534
  const depsBefore = depsFingerprint(cwd);
8495
8535
  try {
8496
8536
  mkdirSync6(visDir);
@@ -8501,7 +8541,7 @@ function runVerify(opts) {
8501
8541
  return 2;
8502
8542
  }
8503
8543
  const visible = runSuite(visDir, cmd, budget);
8504
- if (treeFingerprint(cwd) !== treeBefore) {
8544
+ if (treeFingerprint(cwd, protectedIgnored) !== treeBefore) {
8505
8545
  cleanup([visRoot]);
8506
8546
  out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
8507
8547
  out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
@@ -8529,7 +8569,7 @@ function runVerify(opts) {
8529
8569
  const overlayBefore = overlayDigest(priDir, restored);
8530
8570
  const pristine = runSuite(priDir, cmd, budget);
8531
8571
  const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
8532
- const treeMoved = treeFingerprint(cwd) !== treeBefore;
8572
+ const treeMoved = treeFingerprint(cwd, protectedIgnored) !== treeBefore;
8533
8573
  const depsMoved = depsFingerprint(cwd) !== depsBefore;
8534
8574
  cleanup([visRoot, priRoot]);
8535
8575
  if (overlayMoved || treeMoved || depsMoved) {
@@ -8626,7 +8666,7 @@ function parseVerify(args) {
8626
8666
 
8627
8667
  // src/cli/run.ts
8628
8668
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
8629
- import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync as realpathSync3 } from "node:fs";
8669
+ import { readdirSync as readdirSync4, readFileSync as readFileSync15, readlinkSync as readlinkSync4, realpathSync as realpathSync3 } from "node:fs";
8630
8670
  import { resolve as resolve5 } from "node:path";
8631
8671
  var out = (s) => process.stdout.write(s + "\n");
8632
8672
  var err = (s) => process.stderr.write(s + "\n");
@@ -8657,19 +8697,31 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8657
8697
  for (const pid of pids) {
8658
8698
  const n = Number(pid);
8659
8699
  if (n === process.pid) continue;
8660
- let held;
8661
8700
  try {
8662
- held = realpathSync3(`/proc/${pid}/cwd`);
8701
+ const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
8702
+ const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
8703
+ if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
8663
8704
  } catch {
8664
8705
  continue;
8665
8706
  }
8666
- if (held !== real && !held.startsWith(real + "/")) continue;
8707
+ const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
8667
8708
  try {
8668
- const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
8669
- const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
8670
- if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
8709
+ for (const fd of readdirSync4(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
8671
8710
  } catch {
8672
8711
  }
8712
+ let holds = false;
8713
+ for (const link of procLinks) {
8714
+ try {
8715
+ const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
8716
+ const held = raw.startsWith("/") ? resolve5(raw) : raw;
8717
+ if (held === real || held.startsWith(real + "/")) {
8718
+ holds = true;
8719
+ break;
8720
+ }
8721
+ } catch {
8722
+ }
8723
+ }
8724
+ if (holds) out2.push(n);
8673
8725
  }
8674
8726
  return out2;
8675
8727
  }
@@ -8743,7 +8795,8 @@ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEME
8743
8795
  tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
8744
8796
  return 1;
8745
8797
  }
8746
- const fpBefore = treeFingerprint(cwd);
8798
+ const protectedIgnored = (rel) => isProtected(rel, frozenPolicy);
8799
+ const fpBefore = treeFingerprint(cwd, protectedIgnored);
8747
8800
  let diffCode = 0;
8748
8801
  if (head !== base) {
8749
8802
  out(`
@@ -8760,7 +8813,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
8760
8813
  } catch {
8761
8814
  }
8762
8815
  }
8763
- const mutatedDuringAdjudication = treeFingerprint(cwd) !== fpBefore;
8816
+ const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
8764
8817
  if (mutatedDuringAdjudication) {
8765
8818
  err("tamperward run: the working tree changed while it was being adjudicated \u2014");
8766
8819
  err("the verdict below would describe a tree that no longer exists.");
@@ -8781,7 +8834,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
8781
8834
  err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
8782
8835
  }
8783
8836
  const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
8784
- const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
8837
+ const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
8785
8838
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
8786
8839
  const enforcement = cannot ? 2 : blocked ? 1 : 0;
8787
8840
  const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";