tamperward 2.10.4 → 2.10.6

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 +467 -319
  2. package/package.json +1 -1
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;
@@ -1360,9 +1515,9 @@ import picomatch2 from "picomatch";
1360
1515
  import ts2 from "typescript";
1361
1516
 
1362
1517
  // src/detectors/repo.ts
1363
- import { execFileSync as execFileSync2 } from "node:child_process";
1364
- import { readFileSync } from "node:fs";
1365
- 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";
1366
1521
  var cache = /* @__PURE__ */ new Map();
1367
1522
  function trackedFiles(ctx) {
1368
1523
  if (!ctx) return null;
@@ -1372,7 +1527,7 @@ function trackedFiles(ctx) {
1372
1527
  if (hit !== void 0) return hit;
1373
1528
  let files = null;
1374
1529
  try {
1375
- files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1530
+ files = execFileSync4("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1376
1531
  cwd: ctx.cwd,
1377
1532
  encoding: "utf8",
1378
1533
  maxBuffer: 64 * 1024 * 1024,
@@ -1394,7 +1549,7 @@ function trackedContent(path, ctx) {
1394
1549
  if (hit !== void 0) return hit;
1395
1550
  let src = null;
1396
1551
  try {
1397
- src = readFileSync(join2(ctx.cwd, path), "utf8");
1552
+ src = readFileSync2(join4(ctx.cwd, path), "utf8");
1398
1553
  } catch {
1399
1554
  src = null;
1400
1555
  }
@@ -1409,7 +1564,7 @@ function branchRefs(ctx) {
1409
1564
  if (hit !== void 0) return hit;
1410
1565
  let refs = null;
1411
1566
  try {
1412
- 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"], {
1413
1568
  cwd: ctx.cwd,
1414
1569
  encoding: "utf8",
1415
1570
  stdio: ["ignore", "pipe", "pipe"]
@@ -1432,7 +1587,7 @@ function revIsHead(rev, ctx) {
1432
1587
  if (!ctx?.cwd || rev.startsWith("-")) return null;
1433
1588
  const parse3 = (r) => {
1434
1589
  try {
1435
- 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}`], {
1436
1591
  cwd: ctx.cwd,
1437
1592
  encoding: "utf8",
1438
1593
  stdio: ["ignore", "pipe", "pipe"]
@@ -1452,7 +1607,7 @@ function defaultBranch(ctx) {
1452
1607
  if (hit !== void 0) return hit;
1453
1608
  let name = null;
1454
1609
  try {
1455
- 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"], {
1456
1611
  cwd: ctx.cwd,
1457
1612
  encoding: "utf8",
1458
1613
  stdio: ["ignore", "pipe", "pipe"]
@@ -2706,20 +2861,20 @@ var ciTampering = {
2706
2861
  };
2707
2862
 
2708
2863
  // src/detectors/hook-tampering.ts
2709
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
2710
- 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";
2711
2866
 
2712
2867
  // src/wiring.ts
2713
- import { readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
2868
+ import { readFileSync as readFileSync3, realpathSync as realpathSync2 } from "node:fs";
2714
2869
  import { homedir } from "node:os";
2715
- 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";
2716
2871
  import { fileURLToPath } from "node:url";
2717
2872
  function shippedVersion() {
2718
2873
  try {
2719
2874
  const here = dirname(fileURLToPath(import.meta.url));
2720
2875
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2721
2876
  try {
2722
- const pkg = JSON.parse(readFileSync2(join3(here, rel), "utf8"));
2877
+ const pkg = JSON.parse(readFileSync3(join5(here, rel), "utf8"));
2723
2878
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2724
2879
  } catch {
2725
2880
  }
@@ -2732,10 +2887,12 @@ var TW_VERSION = shippedVersion();
2732
2887
  var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
2733
2888
  var PRE_MATCHER = PRE_TOOLS.join("|");
2734
2889
  var MARKER = "# tamperward: block agent shortcuts before they land";
2735
- var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2736
- var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2737
- var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2738
- 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*$/;
2739
2896
  var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
2740
2897
  function compareVersions(a, b) {
2741
2898
  if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
@@ -2749,7 +2906,7 @@ function pinNotBelow(now, floor) {
2749
2906
  const c = compareVersions(now, floor);
2750
2907
  return c !== null && c >= 0;
2751
2908
  }
2752
- 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*$/;
2753
2910
  function initScriptPin(src) {
2754
2911
  const m = src.match(INIT_SCRIPT);
2755
2912
  return m ? m[1] ?? "" : null;
@@ -2787,7 +2944,7 @@ function canonicalPath(path) {
2787
2944
  }
2788
2945
  function resolvesToClaudeSettings(path, env = process.env) {
2789
2946
  if (isClaudeSettings(path, env)) return true;
2790
- return isAbsolute(path) && isClaudeSettings(canonicalPath(path), env);
2947
+ return isAbsolute3(path) && isClaudeSettings(canonicalPath(path), env);
2791
2948
  }
2792
2949
 
2793
2950
  // src/detectors/policy-diff.ts
@@ -2915,8 +3072,8 @@ function policyAddWeakening(after) {
2915
3072
  }
2916
3073
 
2917
3074
  // src/detectors/hook-wiring.ts
2918
- import { existsSync } from "node:fs";
2919
- 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";
2920
3077
  import { parse as parseYaml } from "yaml";
2921
3078
  function stripComment(line) {
2922
3079
  let single = false;
@@ -4030,16 +4187,16 @@ function expandShellPath(t, env = process.env) {
4030
4187
  }
4031
4188
  function hookTests(policy, ctx) {
4032
4189
  const cwd = ctx?.cwd;
4033
- const onDisk = cwd !== void 0 && existsSync(cwd);
4190
+ const onDisk = cwd !== void 0 && existsSync2(cwd);
4034
4191
  const root = onDisk ? canonicalPath(cwd) : cwd;
4035
4192
  const memo = /* @__PURE__ */ new Map();
4036
4193
  const rel = (t) => {
4037
4194
  const hit = memo.get(t);
4038
4195
  if (hit !== void 0) return hit;
4039
4196
  let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
4040
- const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
4041
- if (pathy && (isAbsolute2(p) || onDisk)) {
4042
- 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));
4043
4200
  if (root !== void 0) {
4044
4201
  if (p === root) p = ".";
4045
4202
  else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
@@ -4226,7 +4383,6 @@ var declares = (s, event) => eventEntries(s, event) !== null;
4226
4383
  var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
4227
4384
  var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
4228
4385
  var SIBLING_KEYS = GATE_KEYS;
4229
- var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
4230
4386
  var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
4231
4387
  var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
4232
4388
  var ENV_MECHANISM = [
@@ -4244,10 +4400,11 @@ function analyseGateCommand(cmd, event) {
4244
4400
  const want = event === "PreToolUse" ? "hook" : "sweep";
4245
4401
  const problems = [];
4246
4402
  let runs = true;
4247
- const canonical2 = cmd.match(CANONICAL_CMD);
4403
+ const canonical2 = cmd.match(OURS);
4248
4404
  if (canonical2) {
4249
- if (canonical2[2] !== want) {
4250
- 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}`);
4251
4408
  runs = false;
4252
4409
  }
4253
4410
  if (!PLAIN_SEMVER.test(canonical2[1])) {
@@ -4597,8 +4754,8 @@ function lefthookBase(c, changes, ctx) {
4597
4754
  if (!ctx?.cwd) return null;
4598
4755
  for (const n of names) {
4599
4756
  try {
4600
- const p = join4(ctx.cwd, n);
4601
- if (existsSync2(p)) return parseDoc(readFileSync3(p, "utf8"));
4757
+ const p = join6(ctx.cwd, n);
4758
+ if (existsSync3(p)) return parseDoc(readFileSync4(p, "utf8"));
4602
4759
  } catch {
4603
4760
  }
4604
4761
  }
@@ -4668,9 +4825,9 @@ function updateIndexHiding(toks, policy) {
4668
4825
  return null;
4669
4826
  }
4670
4827
  function repoRelative(path, ctx) {
4671
- if (!ctx?.cwd || !isAbsolute3(path)) return path;
4672
- const rel = relative(ctx.cwd, path);
4673
- 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;
4674
4831
  }
4675
4832
  var HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"';
4676
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"]);
@@ -4719,7 +4876,7 @@ function huskyRuntimeWrite(path, content, ctx) {
4719
4876
  if (!ok) return false;
4720
4877
  if (!ctx?.cwd) return false;
4721
4878
  const root = path.slice(0, path.length - m[0].length + (m[0].startsWith("/") ? 1 : 0));
4722
- const entry = inspectRel(join4(ctx.cwd, root), ".husky/pre-commit");
4879
+ const entry = inspectRel(join6(ctx.cwd, root), ".husky/pre-commit");
4723
4880
  if (entry.kind !== "file") return false;
4724
4881
  const gate = textOf(entry);
4725
4882
  if (gate == null) return false;
@@ -4972,7 +5129,7 @@ var hookTampering = {
4972
5129
  if (why) {
4973
5130
  const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
4974
5131
  const file = target ? repoRelative(target, ctx) : void 0;
4975
- 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` : "";
4976
5133
  out2.push(
4977
5134
  makeFinding(RULE6, policy, {
4978
5135
  ...file !== void 0 ? { file } : {},
@@ -6099,8 +6256,8 @@ function hasBlocking(findings) {
6099
6256
  }
6100
6257
 
6101
6258
  // src/policy-load.ts
6102
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
6103
- 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";
6104
6261
  import { parse as parse2 } from "yaml";
6105
6262
  var PolicyError = class extends Error {
6106
6263
  };
@@ -6117,7 +6274,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
6117
6274
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6118
6275
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
6119
6276
  function ledgerInsideRepo(ledger) {
6120
- 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;
6121
6278
  const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
6122
6279
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
6123
6280
  }
@@ -6224,9 +6381,9 @@ function parseOrThrow(src, where) {
6224
6381
  return parsePolicy(raw, where);
6225
6382
  }
6226
6383
  function loadPolicy(cwd = process.cwd()) {
6227
- const path = join5(cwd, POLICY_FILE);
6228
- if (!existsSync3(path)) return defaultPolicy();
6229
- 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);
6230
6387
  }
6231
6388
  function loadPolicyAt(rev, cwd) {
6232
6389
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -6235,12 +6392,12 @@ function loadPolicyAt(rev, cwd) {
6235
6392
  }
6236
6393
 
6237
6394
  // src/signoff.ts
6238
- import { createHash } from "node:crypto";
6239
- import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync4 } from "node:fs";
6240
- 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";
6241
6398
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
6242
6399
  function fingerprint(rule, file, evidence) {
6243
- 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);
6244
6401
  }
6245
6402
  var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
6246
6403
  function ledgerPath(cwd, policy) {
@@ -6248,13 +6405,13 @@ function ledgerPath(cwd, policy) {
6248
6405
  if (!ledgerInsideRepo(rel)) {
6249
6406
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
6250
6407
  }
6251
- return join6(cwd, rel);
6408
+ return join8(cwd, rel);
6252
6409
  }
6253
6410
  function readLedger(cwd, policy) {
6254
6411
  const p = ledgerPath(cwd, policy);
6255
- if (!existsSync4(p)) return [];
6412
+ if (!existsSync5(p)) return [];
6256
6413
  const out2 = [];
6257
- for (const line of readFileSync5(p, "utf8").split("\n")) {
6414
+ for (const line of readFileSync6(p, "utf8").split("\n")) {
6258
6415
  if (!line.trim()) continue;
6259
6416
  try {
6260
6417
  const e = JSON.parse(line);
@@ -6294,7 +6451,9 @@ function oobToken(want, oob, head) {
6294
6451
  }
6295
6452
  const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
6296
6453
  if (rule !== want) continue;
6297
- 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;
6298
6457
  }
6299
6458
  return null;
6300
6459
  }
@@ -6310,8 +6469,8 @@ function applyOobSignoffs(findings, oob, head) {
6310
6469
  return { findings: remaining, cleared };
6311
6470
  }
6312
6471
  function oobHeadFromEnv(env = process.env) {
6313
- const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
6314
- 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;
6315
6474
  }
6316
6475
  function oobFromEnv(env = process.env) {
6317
6476
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
@@ -6573,6 +6732,11 @@ function check(opts) {
6573
6732
  } else if (opts.worktree) {
6574
6733
  const p = policy;
6575
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
+ }
6576
6740
  layer = opts.ciLayer ? "ci" : "local";
6577
6741
  view = "worktree";
6578
6742
  } else if (opts.diff) {
@@ -6624,106 +6788,8 @@ function runCheck(opts) {
6624
6788
  }
6625
6789
 
6626
6790
  // src/cli/hook.ts
6627
- import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
6628
- import { createHash as createHash4 } from "node:crypto";
6629
-
6630
- // src/adapters/claude/changes.ts
6631
- import { execFileSync as execFileSync3 } from "node:child_process";
6632
- import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
6633
- import { tmpdir } from "node:os";
6634
- import { isAbsolute as isAbsolute5, join as join7, relative as relative2, resolve as resolve2 } from "node:path";
6635
- function asStr(v) {
6636
- return typeof v === "string" ? v : "";
6637
- }
6638
- function readDisk(path) {
6639
- return textOf(inspectResolved(path));
6640
- }
6641
- function abs(path, cwd) {
6642
- return resolve2(cwd, path);
6643
- }
6644
- function relForDisplay(path, cwd) {
6645
- const rel = relative2(cwd, path);
6646
- return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
6647
- }
6648
- function applyEdit(content, oldStr, newStr) {
6649
- if (content === null) return newStr;
6650
- if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
6651
- return content.replace(oldStr, () => newStr);
6652
- }
6653
- function synthFileChange(displayPath, before, after) {
6654
- if (before === after) return [];
6655
- let op;
6656
- if (before === null) op = "add";
6657
- else if (after === null) op = "delete";
6658
- else op = "modify";
6659
- const dir = mkdtempSync(join7(tmpdir(), "hf-"));
6660
- let raw = "";
6661
- try {
6662
- const a = join7(dir, "a");
6663
- const b = join7(dir, "b");
6664
- writeFileSync(a, before ?? "");
6665
- writeFileSync(b, after ?? "");
6666
- try {
6667
- raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
6668
- encoding: "utf8",
6669
- maxBuffer: 256 * 1024 * 1024
6670
- });
6671
- } catch (e) {
6672
- const err2 = e;
6673
- if (err2.status !== 1) {
6674
- throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
6675
- }
6676
- raw = err2.stdout ? String(err2.stdout) : "";
6677
- }
6678
- } finally {
6679
- rmSync(dir, { recursive: true, force: true });
6680
- }
6681
- const first = parseDiff(raw)[0];
6682
- const hunks = first && first.kind === "file" ? first.hunks : [];
6683
- return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
6684
- }
6685
- function changesFromClaudeHook(input, cwd) {
6686
- const ti = input.tool_input ?? {};
6687
- switch (input.tool_name) {
6688
- case "Bash": {
6689
- const raw = asStr(ti.command);
6690
- return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
6691
- }
6692
- case "Write": {
6693
- const fp = asStr(ti.file_path);
6694
- if (!fp) return [];
6695
- const before = readDisk(abs(fp, cwd));
6696
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
6697
- }
6698
- case "Edit": {
6699
- const fp = asStr(ti.file_path);
6700
- if (!fp) return [];
6701
- const before = readDisk(abs(fp, cwd));
6702
- const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
6703
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
6704
- }
6705
- case "MultiEdit": {
6706
- const fp = asStr(ti.file_path);
6707
- if (!fp) return [];
6708
- const before = readDisk(abs(fp, cwd));
6709
- let after = before;
6710
- const edits = Array.isArray(ti.edits) ? ti.edits : [];
6711
- for (const raw of edits) {
6712
- const ed = raw;
6713
- after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
6714
- }
6715
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
6716
- }
6717
- case "NotebookEdit": {
6718
- const fp = asStr(ti.notebook_path);
6719
- const src = asStr(ti.new_source);
6720
- if (!fp || !src) return [];
6721
- return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
6722
- }
6723
- default:
6724
- return [];
6725
- }
6726
- }
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";
6727
6793
 
6728
6794
  // src/adapters/claude/deny.ts
6729
6795
  function formatDenial(blocks) {
@@ -6745,22 +6811,22 @@ function formatDenial(blocks) {
6745
6811
  }
6746
6812
 
6747
6813
  // src/session.ts
6748
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
6749
- 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";
6750
6816
  var UNSAFE = /[^A-Za-z0-9_-]/g;
6751
6817
  var SHA = /^[0-9a-f]{7,40}$/;
6752
6818
  function baselinePath(cwd, sessionId) {
6753
6819
  const gd = gitDir(cwd);
6754
6820
  if (!gd) return null;
6755
- return join8(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
6821
+ return join9(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
6756
6822
  }
6757
6823
  function turnBaseline(cwd, sessionId) {
6758
6824
  if (!sessionId) return null;
6759
6825
  try {
6760
6826
  const p = baselinePath(cwd, sessionId);
6761
6827
  if (!p) return null;
6762
- if (existsSync5(p)) {
6763
- const v = readFileSync6(p, "utf8").trim();
6828
+ if (existsSync6(p)) {
6829
+ const v = readFileSync7(p, "utf8").trim();
6764
6830
  if (SHA.test(v)) return v;
6765
6831
  }
6766
6832
  const head = headSha(cwd);
@@ -6785,21 +6851,21 @@ function advanceTurnBaseline(cwd, sessionId) {
6785
6851
  }
6786
6852
 
6787
6853
  // src/effect.ts
6788
- import { createHash as createHash2 } from "node:crypto";
6789
- import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6790
- import { isAbsolute as isAbsolute6, join as join9 } from "node:path";
6791
- 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";
6792
6858
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
6793
6859
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
6794
6860
  function gitDir2(cwd) {
6795
6861
  try {
6796
- const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
6862
+ const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
6797
6863
  cwd,
6798
6864
  encoding: "utf8",
6799
6865
  stdio: ["ignore", "pipe", "pipe"]
6800
6866
  }).trim();
6801
6867
  if (!d) return null;
6802
- return isAbsolute6(d) ? d : join9(cwd, d);
6868
+ return isAbsolute7(d) ? d : join10(cwd, d);
6803
6869
  } catch {
6804
6870
  return null;
6805
6871
  }
@@ -6808,8 +6874,8 @@ function statePath(cwd, sessionId, kind) {
6808
6874
  if (!sessionId) return null;
6809
6875
  const gd = gitDir2(cwd);
6810
6876
  if (!gd) return null;
6811
- const dir = join9(gd, "tamperward");
6812
- return join9(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
6877
+ const dir = join10(gd, "tamperward");
6878
+ return join10(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
6813
6879
  }
6814
6880
  function ptreePath(cwd, sessionId) {
6815
6881
  return statePath(cwd, sessionId, "ptree");
@@ -6817,7 +6883,7 @@ function ptreePath(cwd, sessionId) {
6817
6883
  function turnTreePath(cwd, sessionId) {
6818
6884
  return statePath(cwd, sessionId, "turntree");
6819
6885
  }
6820
- 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);
6821
6887
  function contentHash(content) {
6822
6888
  return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
6823
6889
  }
@@ -6838,7 +6904,7 @@ function snapshotProtected(cwd, policy, prev) {
6838
6904
  } catch {
6839
6905
  }
6840
6906
  const walk2 = (rel) => {
6841
- const abs2 = rel ? join9(cwd, rel) : cwd;
6907
+ const abs2 = rel ? join10(cwd, rel) : cwd;
6842
6908
  let names;
6843
6909
  try {
6844
6910
  names = readdirSync(abs2);
@@ -6851,7 +6917,7 @@ function snapshotProtected(cwd, policy, prev) {
6851
6917
  if (pruned.has(r)) continue;
6852
6918
  let st;
6853
6919
  try {
6854
- st = lstatSync2(join9(cwd, r));
6920
+ st = lstatSync2(join10(cwd, r));
6855
6921
  } catch {
6856
6922
  continue;
6857
6923
  }
@@ -6878,9 +6944,9 @@ function driftBetween(expected, current) {
6878
6944
  return { changed, deleted, added };
6879
6945
  }
6880
6946
  function loadTree(p) {
6881
- if (!p || !existsSync6(p)) return null;
6947
+ if (!p || !existsSync7(p)) return null;
6882
6948
  try {
6883
- return JSON.parse(readFileSync7(p, "utf8"));
6949
+ return JSON.parse(readFileSync8(p, "utf8"));
6884
6950
  } catch {
6885
6951
  return null;
6886
6952
  }
@@ -6888,7 +6954,7 @@ function loadTree(p) {
6888
6954
  function saveTree(p, tree) {
6889
6955
  if (!p) return;
6890
6956
  try {
6891
- mkdirSync3(join9(p, ".."), { recursive: true });
6957
+ mkdirSync3(join10(p, ".."), { recursive: true });
6892
6958
  const tmp = `${p}.${process.pid}.tmp`;
6893
6959
  writeFileSync3(tmp, JSON.stringify(tree));
6894
6960
  renameSync(tmp, p);
@@ -6920,19 +6986,19 @@ function dropTurnTree(cwd, sessionId) {
6920
6986
 
6921
6987
  // src/cli/watch.ts
6922
6988
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
6923
- import { join as join10 } from "node:path";
6924
- import { createHash as createHash3 } from "node:crypto";
6989
+ import { join as join11 } from "node:path";
6990
+ import { createHash as createHash4 } from "node:crypto";
6925
6991
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
6926
6992
  function snap(abs2) {
6927
6993
  const e = inspectPath(abs2);
6928
6994
  if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
6929
6995
  if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
6930
- 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);
6931
6997
  return { mode: e.mode, size: e.size, hash };
6932
6998
  }
6933
6999
  function defaultEventLog(cwd) {
6934
7000
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
6935
- return join10(gitDir(cwd) ?? join10(cwd, ".git"), "tamperward", "fsevents.jsonl");
7001
+ return join11(gitDir(cwd) ?? join11(cwd, ".git"), "tamperward", "fsevents.jsonl");
6936
7002
  }
6937
7003
  function watchTree(dir, cb) {
6938
7004
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -6949,12 +7015,12 @@ function watchTree(dir, cb) {
6949
7015
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
6950
7016
  let w;
6951
7017
  try {
6952
- w = watch(rel ? join10(dir, rel) : dir, (kind, fname) => {
7018
+ w = watch(rel ? join11(dir, rel) : dir, (kind, fname) => {
6953
7019
  if (!fname) return;
6954
7020
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
6955
7021
  cb(kind, child);
6956
7022
  try {
6957
- if (statSync(join10(dir, child)).isDirectory()) addDir(child);
7023
+ if (statSync(join11(dir, child)).isDirectory()) addDir(child);
6958
7024
  } catch {
6959
7025
  }
6960
7026
  });
@@ -6967,7 +7033,7 @@ function watchTree(dir, cb) {
6967
7033
  addDir(rel);
6968
7034
  let names = [];
6969
7035
  try {
6970
- names = readdirSync2(rel ? join10(dir, rel) : dir);
7036
+ names = readdirSync2(rel ? join11(dir, rel) : dir);
6971
7037
  } catch {
6972
7038
  return;
6973
7039
  }
@@ -6975,7 +7041,7 @@ function watchTree(dir, cb) {
6975
7041
  const child = rel ? `${rel}/${name}` : name;
6976
7042
  if (SKIP2.test(child + "/")) continue;
6977
7043
  try {
6978
- if (statSync(join10(dir, child)).isDirectory()) walk2(child);
7044
+ if (statSync(join11(dir, child)).isDirectory()) walk2(child);
6979
7045
  } catch {
6980
7046
  }
6981
7047
  }
@@ -6987,13 +7053,13 @@ function watchTree(dir, cb) {
6987
7053
  }
6988
7054
  function startWatcher(dir, log, policy) {
6989
7055
  try {
6990
- mkdirSync4(join10(log, ".."), { recursive: true });
7056
+ mkdirSync4(join11(log, ".."), { recursive: true });
6991
7057
  } catch {
6992
7058
  }
6993
7059
  const last = /* @__PURE__ */ new Map();
6994
7060
  return watchTree(dir, (kind, rel) => {
6995
7061
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
6996
- const s = snap(join10(dir, rel));
7062
+ const s = snap(join11(dir, rel));
6997
7063
  const key2 = `${s.hash}:${s.mode}`;
6998
7064
  if (last.get(rel) === key2) return;
6999
7065
  last.set(rel, key2);
@@ -7020,7 +7086,7 @@ function runWatch(args) {
7020
7086
  }
7021
7087
 
7022
7088
  // src/detectors/fs-events.ts
7023
- import { readFileSync as readFileSync9, statSync as statSync2 } from "node:fs";
7089
+ import { readFileSync as readFileSync10, statSync as statSync2 } from "node:fs";
7024
7090
  var RULE11 = "transient-protected-mutation";
7025
7091
  function readEvents(log, offset) {
7026
7092
  let size = 0;
@@ -7030,7 +7096,7 @@ function readEvents(log, offset) {
7030
7096
  return { events: [], newOffset: offset };
7031
7097
  }
7032
7098
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
7033
- const raw = readFileSync9(log, "utf8").slice(offset);
7099
+ const raw = readFileSync10(log, "utf8").slice(offset);
7034
7100
  const events = [];
7035
7101
  for (const line of raw.split("\n")) {
7036
7102
  if (!line.trim()) continue;
@@ -7083,7 +7149,7 @@ var HookInputError = class extends Error {
7083
7149
  };
7084
7150
  function readStdin() {
7085
7151
  try {
7086
- return readFileSync10(0, "utf8");
7152
+ return readFileSync11(0, "utf8");
7087
7153
  } catch (e) {
7088
7154
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
7089
7155
  }
@@ -7299,7 +7365,7 @@ function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
7299
7365
  let dirty = false;
7300
7366
  for (const c of changes) {
7301
7367
  if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
7302
- 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);
7303
7369
  const prev = tree[c.path];
7304
7370
  tree[c.path] = {
7305
7371
  hash,
@@ -7336,10 +7402,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7336
7402
  } };
7337
7403
  const log = defaultEventLog(cwd);
7338
7404
  const cp = cursorPath(cwd, sessionId);
7339
- if (!existsSync7(log)) return none;
7405
+ if (!existsSync8(log)) return none;
7340
7406
  let offset = 0;
7341
- if (cp && existsSync7(cp)) {
7342
- const n = Number(readFileSync10(cp, "utf8"));
7407
+ if (cp && existsSync8(cp)) {
7408
+ const n = Number(readFileSync11(cp, "utf8"));
7343
7409
  offset = Number.isFinite(n) ? n : 0;
7344
7410
  }
7345
7411
  const { events, newOffset } = readEvents(log, offset);
@@ -7490,15 +7556,15 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
7490
7556
  }
7491
7557
 
7492
7558
  // src/cli/init.ts
7493
- import { execFileSync as execFileSync5 } from "node:child_process";
7494
- import { createHash as createHash5 } from "node:crypto";
7495
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7496
- 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";
7497
7563
  var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
7498
7564
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7499
7565
  function gitConfig(cwd, key2) {
7500
7566
  try {
7501
- 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;
7502
7568
  } catch {
7503
7569
  return null;
7504
7570
  }
@@ -7506,6 +7572,8 @@ function gitConfig(cwd, key2) {
7506
7572
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
7507
7573
  var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
7508
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*$/;
7509
7577
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
7510
7578
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
7511
7579
  function coveredBy(existing, critical) {
@@ -7550,12 +7618,16 @@ version: 1
7550
7618
  # ignore: [] # visible blind spots \u2014 the count is always reported
7551
7619
  `;
7552
7620
  function pinOf(command) {
7553
- const m = command.match(OURS);
7621
+ const m = ours(command);
7554
7622
  return m ? m[1] ?? "" : null;
7555
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
+ }
7556
7628
  function stalePin(command) {
7557
7629
  const pin = pinOf(command);
7558
- return pin !== null && pin !== TW_VERSION;
7630
+ return pin !== null && (pin !== TW_VERSION || !OURS.test(command));
7559
7631
  }
7560
7632
  var WORKFLOW_CONTENT = `name: tamperward
7561
7633
 
@@ -7579,22 +7651,20 @@ jobs:
7579
7651
  tamperward:
7580
7652
  runs-on: ubuntu-latest
7581
7653
  timeout-minutes: 10
7582
- # The gate is fetched from the registry AT GATE TIME, from inside the
7583
- # checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
7584
- # directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
7585
- # the authority judging it is downloaded from; the version pin below pins the
7586
- # STRING, not the SOURCE. An environment variable outranks a project
7587
- # \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
7588
- # something.
7589
- env:
7590
- NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
7591
7654
  steps:
7592
- - uses: actions/checkout@v5
7593
- with:
7594
- fetch-depth: 0 # the range diff needs both endpoints
7595
7655
  - uses: actions/setup-node@v6
7596
7656
  with:
7597
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
7598
7668
  - name: Resolve out-of-band sign-off from PR labels
7599
7669
  id: oob
7600
7670
  env:
@@ -7609,7 +7679,7 @@ jobs:
7609
7679
  # across pushes, so an unbound one would clear every later finding on
7610
7680
  # the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
7611
7681
  TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
7612
- 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 }}"
7613
7683
  # Diff-time detection is spelling-dependent by nature; pristine
7614
7684
  # re-execution is not, and it was the layer nothing got past in the
7615
7685
  # taskbench sweeps. Shipping a CI template WITHOUT it meant every
@@ -7627,12 +7697,12 @@ jobs:
7627
7697
  # stays red whatever the labels say.
7628
7698
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
7629
7699
  TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
7630
- 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 }}"
7631
7701
  `;
7632
7702
  var WORKFLOW_MARK = "# tamperward:generated";
7633
7703
  var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
7634
7704
  function bodyHash(body) {
7635
- return createHash5("sha256").update(body).digest("hex").slice(0, 16);
7705
+ return createHash6("sha256").update(body).digest("hex").slice(0, 16);
7636
7706
  }
7637
7707
  function workflowFile(body) {
7638
7708
  return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
@@ -7669,8 +7739,8 @@ function hooksShapeError(hooks) {
7669
7739
  return null;
7670
7740
  }
7671
7741
  function planPolicy(cwd) {
7672
- const path = join11(cwd, POLICY_FILE);
7673
- if (existsSync8(path)) {
7742
+ const path = join12(cwd, POLICY_FILE);
7743
+ if (existsSync9(path)) {
7674
7744
  try {
7675
7745
  loadPolicy(cwd);
7676
7746
  } catch (e) {
@@ -7688,12 +7758,12 @@ function planPolicy(cwd) {
7688
7758
  }
7689
7759
  function planClaudeHooks(cwd) {
7690
7760
  const rel = ".claude/settings.json";
7691
- const path = join11(cwd, rel);
7761
+ const path = join12(cwd, rel);
7692
7762
  let settings = {};
7693
- if (existsSync8(path)) {
7763
+ if (existsSync9(path)) {
7694
7764
  let parsed;
7695
7765
  try {
7696
- parsed = JSON.parse(readFileSync11(path, "utf8"));
7766
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
7697
7767
  } catch {
7698
7768
  return {
7699
7769
  item: "agent",
@@ -7728,7 +7798,7 @@ function planClaudeHooks(cwd) {
7728
7798
  return {
7729
7799
  item: "agent",
7730
7800
  path: rel,
7731
- status: existsSync8(path) ? "update" : "create",
7801
+ status: existsSync9(path) ? "update" : "create",
7732
7802
  detail: [
7733
7803
  needPre && "wire PreToolUse deny",
7734
7804
  needStop && "wire Stop sweep",
@@ -7738,7 +7808,7 @@ function planClaudeHooks(cwd) {
7738
7808
  ].filter(Boolean).join(" + "),
7739
7809
  apply: () => {
7740
7810
  for (const h of repin) {
7741
- const kind = String(h.command ?? "").match(OURS)?.[2];
7811
+ const kind = ours(String(h.command ?? ""))?.[2];
7742
7812
  h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
7743
7813
  }
7744
7814
  if (needPre) {
@@ -7785,15 +7855,15 @@ var MANAGED_HOOKS = [
7785
7855
  ];
7786
7856
  function hooksDir(cwd) {
7787
7857
  try {
7788
- 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();
7789
7859
  if (out2) return resolve3(cwd, out2);
7790
7860
  } catch {
7791
7861
  }
7792
- return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
7862
+ return existsSync9(join12(cwd, ".git")) ? join12(cwd, ".git", "hooks") : null;
7793
7863
  }
7794
7864
  function display(cwd, path) {
7795
7865
  const rel = relative3(cwd, path);
7796
- return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
7866
+ return rel && !rel.startsWith("..") && !isAbsolute8(rel) ? rel : path;
7797
7867
  }
7798
7868
  function unconditionalExitAt(lines) {
7799
7869
  let depth = 0;
@@ -7816,30 +7886,30 @@ function planPreCommit(cwd) {
7816
7886
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
7817
7887
  let path;
7818
7888
  let note;
7819
- if (existsSync8(join11(cwd, ".husky"))) {
7820
- path = join11(cwd, ".husky", "pre-commit");
7889
+ if (existsSync9(join12(cwd, ".husky"))) {
7890
+ path = join12(cwd, ".husky", "pre-commit");
7821
7891
  note = "husky";
7822
7892
  } else {
7823
- 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))));
7824
7894
  if (managed) {
7825
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}`);
7826
7896
  }
7827
7897
  const dir = hooksDir(cwd);
7828
7898
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
7829
7899
  if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
7830
- path = join11(dirname4(dir), "pre-commit");
7900
+ path = join12(dirname4(dir), "pre-commit");
7831
7901
  note = "husky";
7832
7902
  } else {
7833
- path = join11(dir, "pre-commit");
7903
+ path = join12(dir, "pre-commit");
7834
7904
  const rel2 = display(cwd, dir);
7835
- 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)`;
7836
7906
  }
7837
7907
  }
7838
7908
  const rel = display(cwd, path);
7839
- if (existsSync8(path) && !statSync3(path).isFile()) {
7909
+ if (existsSync9(path) && !statSync3(path).isFile()) {
7840
7910
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
7841
7911
  }
7842
- const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
7912
+ const existing = existsSync9(path) ? readFileSync12(path, "utf8") : null;
7843
7913
  if (existing === null) {
7844
7914
  return {
7845
7915
  item: "pre-commit",
@@ -7883,8 +7953,8 @@ ${block.join("\n")}
7883
7953
  };
7884
7954
  }
7885
7955
  if (at !== -1) {
7886
- const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
7887
- if (!ours) {
7956
+ const ours2 = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
7957
+ if (!ours2) {
7888
7958
  return {
7889
7959
  item: "pre-commit",
7890
7960
  path: rel,
@@ -7928,10 +7998,10 @@ ${block.join("\n")}
7928
7998
  function planCodeowners(cwd) {
7929
7999
  const rel = ".github/CODEOWNERS";
7930
8000
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
7931
- (r) => existsSync8(join11(cwd, r))
8001
+ (r) => existsSync9(join12(cwd, r))
7932
8002
  );
7933
- const path = join11(cwd, existingRel ?? rel);
7934
- const existing = existingRel ? readFileSync11(path, "utf8") : null;
8003
+ const path = join12(cwd, existingRel ?? rel);
8004
+ const existing = existingRel ? readFileSync12(path, "utf8") : null;
7935
8005
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
7936
8006
  if (missing.length === 0) {
7937
8007
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -7964,12 +8034,12 @@ ${CODEOWNERS_MARK}
7964
8034
  }
7965
8035
  function planWorkflow(cwd, force) {
7966
8036
  const rel = ".github/workflows/tamperward.yml";
7967
- const path = join11(cwd, rel);
8037
+ const path = join12(cwd, rel);
7968
8038
  const write = () => {
7969
8039
  mkdirSync5(dirname4(path), { recursive: true });
7970
8040
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
7971
8041
  };
7972
- if (!existsSync8(path)) {
8042
+ if (!existsSync9(path)) {
7973
8043
  return {
7974
8044
  item: "ci",
7975
8045
  path: rel,
@@ -7980,7 +8050,7 @@ function planWorkflow(cwd, force) {
7980
8050
  }
7981
8051
  let src;
7982
8052
  try {
7983
- src = readFileSync11(path, "utf8");
8053
+ src = readFileSync12(path, "utf8");
7984
8054
  } catch {
7985
8055
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
7986
8056
  }
@@ -8079,79 +8149,33 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
8079
8149
  // src/cli/verify.ts
8080
8150
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
8081
8151
  import { createHash as createHash8 } from "node:crypto";
8082
- import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
8152
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync3, realpathSync as realpathSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
8083
8153
  import { tmpdir as tmpdir2 } from "node:os";
8084
- import { dirname as dirname5, join as join14, resolve as resolve4 } from "node:path";
8085
-
8086
- // src/git/trusted.ts
8087
- import { execFileSync as execFileSync6 } from "node:child_process";
8088
- import { createHash as createHash6 } from "node:crypto";
8089
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
8090
- import { isAbsolute as isAbsolute8, join as join12 } from "node:path";
8091
- function trustedGitEnv() {
8092
- return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
8093
- }
8094
- function gitDirOf(cwd) {
8095
- try {
8096
- const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
8097
- cwd,
8098
- encoding: "utf8",
8099
- stdio: ["ignore", "pipe", "pipe"],
8100
- env: trustedGitEnv()
8101
- }).trim();
8102
- if (!d) return null;
8103
- return isAbsolute8(d) ? d : join12(cwd, d);
8104
- } catch {
8105
- return null;
8106
- }
8107
- }
8108
- function objectRewriteState(cwd) {
8109
- const parts = [];
8110
- try {
8111
- parts.push(
8112
- "replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
8113
- cwd,
8114
- encoding: "utf8",
8115
- stdio: ["ignore", "pipe", "pipe"],
8116
- env: trustedGitEnv()
8117
- }).trim()
8118
- );
8119
- } catch {
8120
- parts.push("replace:<unreadable>");
8121
- }
8122
- const dir = gitDirOf(cwd);
8123
- for (const rel of ["info/grafts", "shallow"]) {
8124
- if (dir === null) {
8125
- parts.push(`${rel}:<no-git-dir>`);
8126
- continue;
8127
- }
8128
- const p = join12(dir, rel);
8129
- try {
8130
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
8131
- } catch {
8132
- parts.push(`${rel}:<unreadable>`);
8133
- }
8134
- }
8135
- return createHash6("sha256").update(parts.join("\n")).digest("hex");
8136
- }
8154
+ import { dirname as dirname5, isAbsolute as isAbsolute9, join as join14, relative as relative4, resolve as resolve4, sep } from "node:path";
8137
8155
 
8138
8156
  // src/fingerprint.ts
8139
8157
  import { execFileSync as execFileSync7 } from "node:child_process";
8140
8158
  import { createHash as createHash7 } from "node:crypto";
8141
8159
  import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
8142
8160
  import { join as join13 } from "node:path";
8143
- function treeFingerprint(cwd) {
8144
- 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"], {
8145
8163
  cwd,
8146
8164
  encoding: "utf8",
8147
8165
  maxBuffer: 1 << 28,
8148
8166
  env: trustedGitEnv()
8149
- }).split("\0").filter(Boolean).sort();
8167
+ }).split("\0").filter(Boolean);
8168
+ const files = [.../* @__PURE__ */ new Set([...listed, ...keepIgnored ? ignoredPaths({ cwd }, keepIgnored) : []])].sort();
8150
8169
  const h = createHash7("sha256");
8151
8170
  for (const rel of files) {
8152
8171
  h.update(rel);
8153
8172
  h.update("\0");
8154
- 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}>`);
8155
8179
  h.update("\0");
8156
8180
  }
8157
8181
  return h.digest("hex");
@@ -8284,29 +8308,131 @@ function baseIsAncestorOfHead(base, cwd) {
8284
8308
  const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
8285
8309
  return r.status === 0;
8286
8310
  }
8311
+ function inside(root, path) {
8312
+ const rel = relative4(resolve4(root), resolve4(path));
8313
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute9(rel);
8314
+ }
8315
+ function linkParts(path) {
8316
+ const parts = sep === "\\" ? path.split(/[\\/]+/) : path.split("/");
8317
+ return parts.filter((part) => part !== "" && part !== ".");
8318
+ }
8319
+ function linkEscape(label, target) {
8320
+ return new Error(`${label} is a symlink that escapes the materialised tree (${JSON.stringify(target)})`);
8321
+ }
8322
+ function driveRelativeLinkTarget(target) {
8323
+ return sep === "\\" && /^[A-Za-z]:(?![\\/])/.test(target);
8324
+ }
8325
+ function rootedLinkTarget(target) {
8326
+ return isAbsolute9(target) || driveRelativeLinkTarget(target);
8327
+ }
8328
+ function safeSymlink(target, out2, root, label) {
8329
+ if (rootedLinkTarget(target) || !inside(root, resolve4(dirname5(out2), target))) {
8330
+ throw linkEscape(label, target);
8331
+ }
8332
+ rmSync3(out2, { force: true });
8333
+ symlinkSync(target, out2);
8334
+ }
8335
+ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
8336
+ let domain = "tree";
8337
+ let stack = linkParts(relative4(root, dirname5(out2)));
8338
+ const pending = linkParts(target);
8339
+ const seen = /* @__PURE__ */ new Set();
8340
+ let hops = 0;
8341
+ while (pending.length) {
8342
+ const part = pending.shift();
8343
+ if (part === "..") {
8344
+ if (!stack.length) throw linkEscape(label, target);
8345
+ stack.pop();
8346
+ continue;
8347
+ }
8348
+ stack.push(part);
8349
+ if (domain === "tree" && dependencyRoot && stack.length === 1 && stack[0] === "node_modules") {
8350
+ domain = "deps";
8351
+ stack = [];
8352
+ continue;
8353
+ }
8354
+ const base = domain === "tree" ? root : dependencyRoot;
8355
+ const current = join14(base, ...stack);
8356
+ let st;
8357
+ try {
8358
+ st = lstatSync3(current);
8359
+ } catch {
8360
+ continue;
8361
+ }
8362
+ if (!st.isSymbolicLink()) continue;
8363
+ if (++hops > 128) {
8364
+ throw new Error(`${label} contains a symlink cycle while materialising (${JSON.stringify(target)})`);
8365
+ }
8366
+ const key2 = `${domain}:${current}:${pending.join(sep)}`;
8367
+ if (seen.has(key2)) {
8368
+ throw new Error(`${label} contains a symlink cycle while materialising (${JSON.stringify(target)})`);
8369
+ }
8370
+ seen.add(key2);
8371
+ const next = readlinkSync3(current);
8372
+ stack.pop();
8373
+ if (driveRelativeLinkTarget(next)) {
8374
+ throw linkEscape(label, target);
8375
+ }
8376
+ if (isAbsolute9(next)) {
8377
+ if (domain === "tree" || !dependencyRoot || !inside(dependencyRoot, next)) {
8378
+ throw linkEscape(label, target);
8379
+ }
8380
+ stack = [];
8381
+ pending.unshift(...linkParts(relative4(dependencyRoot, resolve4(next))));
8382
+ } else {
8383
+ pending.unshift(...linkParts(next));
8384
+ }
8385
+ }
8386
+ }
8387
+ function rejectLinkedParent(cwd, rel) {
8388
+ const parts = rel.split("/").slice(0, -1);
8389
+ let current = cwd;
8390
+ for (const part of parts) {
8391
+ current = join14(current, part);
8392
+ let st;
8393
+ try {
8394
+ st = lstatSync3(current);
8395
+ } catch {
8396
+ continue;
8397
+ }
8398
+ if (st.isSymbolicLink()) {
8399
+ throw new Error(`${rel} has a symlinked parent directory (${relative4(cwd, current)})`);
8400
+ }
8401
+ }
8402
+ }
8287
8403
  function materialize(cwd, dest) {
8288
8404
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
8405
+ const links = [];
8289
8406
  for (const rel of listed) {
8290
8407
  const src = join14(cwd, rel);
8408
+ rejectLinkedParent(cwd, rel);
8291
8409
  let st;
8292
8410
  try {
8293
- st = statSync5(src);
8411
+ st = lstatSync3(src);
8294
8412
  } catch {
8295
8413
  continue;
8296
8414
  }
8297
- if (!st.isFile()) continue;
8298
8415
  const out2 = join14(dest, rel);
8299
8416
  mkdirSync6(dirname5(out2), { recursive: true });
8300
8417
  dropSymlink(out2);
8301
- if (lstatSync3(src).isSymbolicLink()) {
8302
- writeFileSync6(out2, readFileSync14(src));
8303
- } else {
8418
+ if (st.isSymbolicLink()) {
8419
+ const target = readlinkSync3(src);
8420
+ safeSymlink(target, out2, dest, rel);
8421
+ links.push({ rel, out: out2, target });
8422
+ } else if (st.isFile()) {
8304
8423
  cpSync(src, out2, { dereference: true });
8424
+ chmodSync2(out2, st.mode);
8425
+ } else {
8426
+ throw new Error(`${rel} is ${st.isDirectory() ? "a directory" : "a special file"} where git expects a file`);
8305
8427
  }
8306
- chmodSync2(out2, st.mode);
8307
8428
  }
8308
8429
  const nm = resolve4(cwd, "node_modules");
8309
- if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
8430
+ let dependencyRoot = null;
8431
+ if (existsSync10(nm)) dependencyRoot = realpathSync3(nm);
8432
+ for (const link of links) {
8433
+ validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, link.rel);
8434
+ }
8435
+ if (dependencyRoot) symlinkSync(nm, join14(dest, "node_modules"), "dir");
8310
8436
  }
8311
8437
  function dropSymlink(p) {
8312
8438
  try {
@@ -8358,6 +8484,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
8358
8484
  const verifierGlobs = policy.verify?.inputs ?? [];
8359
8485
  const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
8360
8486
  const restored = [];
8487
+ const restoredLinks = [];
8361
8488
  const baseProtected = /* @__PURE__ */ new Set();
8362
8489
  for (const e of entries) {
8363
8490
  if (!isOverlay(e.path)) continue;
@@ -8370,7 +8497,9 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
8370
8497
  mkdirSync6(dirname5(out2), { recursive: true });
8371
8498
  rmSync3(out2, { force: true });
8372
8499
  if (e.mode === "120000") {
8373
- symlinkSync(content.toString("utf8"), out2);
8500
+ const target = content.toString("utf8");
8501
+ safeSymlink(target, out2, dest, `the base's ${e.path}`);
8502
+ restoredLinks.push({ path: e.path, out: out2, target });
8374
8503
  } else {
8375
8504
  writeFileSync6(out2, content);
8376
8505
  chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
@@ -8384,6 +8513,11 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
8384
8513
  rmSync3(join14(dest, rel), { force: true });
8385
8514
  removed++;
8386
8515
  }
8516
+ const nm = resolve4(cwd, "node_modules");
8517
+ const dependencyRoot = existsSync10(nm) ? realpathSync3(nm) : null;
8518
+ for (const link of restoredLinks) {
8519
+ validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, `the base's ${link.path}`);
8520
+ }
8387
8521
  return { restored, removed };
8388
8522
  }
8389
8523
  var SUPERVISOR = `
@@ -8505,7 +8639,8 @@ function runVerify(opts) {
8505
8639
  if (opts.keep) return;
8506
8640
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
8507
8641
  };
8508
- const treeBefore = treeFingerprint(cwd);
8642
+ const protectedIgnored = (rel) => isProtected(rel, policy);
8643
+ const treeBefore = treeFingerprint(cwd, protectedIgnored);
8509
8644
  const depsBefore = depsFingerprint(cwd);
8510
8645
  try {
8511
8646
  mkdirSync6(visDir);
@@ -8516,7 +8651,7 @@ function runVerify(opts) {
8516
8651
  return 2;
8517
8652
  }
8518
8653
  const visible = runSuite(visDir, cmd, budget);
8519
- if (treeFingerprint(cwd) !== treeBefore) {
8654
+ if (treeFingerprint(cwd, protectedIgnored) !== treeBefore) {
8520
8655
  cleanup([visRoot]);
8521
8656
  out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
8522
8657
  out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
@@ -8544,7 +8679,7 @@ function runVerify(opts) {
8544
8679
  const overlayBefore = overlayDigest(priDir, restored);
8545
8680
  const pristine = runSuite(priDir, cmd, budget);
8546
8681
  const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
8547
- const treeMoved = treeFingerprint(cwd) !== treeBefore;
8682
+ const treeMoved = treeFingerprint(cwd, protectedIgnored) !== treeBefore;
8548
8683
  const depsMoved = depsFingerprint(cwd) !== depsBefore;
8549
8684
  cleanup([visRoot, priRoot]);
8550
8685
  if (overlayMoved || treeMoved || depsMoved) {
@@ -8641,7 +8776,7 @@ function parseVerify(args) {
8641
8776
 
8642
8777
  // src/cli/run.ts
8643
8778
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
8644
- import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync as realpathSync3 } from "node:fs";
8779
+ import { readdirSync as readdirSync4, readFileSync as readFileSync15, readlinkSync as readlinkSync4, realpathSync as realpathSync4 } from "node:fs";
8645
8780
  import { resolve as resolve5 } from "node:path";
8646
8781
  var out = (s) => process.stdout.write(s + "\n");
8647
8782
  var err = (s) => process.stderr.write(s + "\n");
@@ -8659,7 +8794,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8659
8794
  const out2 = [];
8660
8795
  let real;
8661
8796
  try {
8662
- real = realpathSync3(cwd);
8797
+ real = realpathSync4(cwd);
8663
8798
  } catch {
8664
8799
  return out2;
8665
8800
  }
@@ -8672,19 +8807,31 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8672
8807
  for (const pid of pids) {
8673
8808
  const n = Number(pid);
8674
8809
  if (n === process.pid) continue;
8675
- let held;
8676
8810
  try {
8677
- held = realpathSync3(`/proc/${pid}/cwd`);
8811
+ const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
8812
+ const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
8813
+ if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
8678
8814
  } catch {
8679
8815
  continue;
8680
8816
  }
8681
- if (held !== real && !held.startsWith(real + "/")) continue;
8817
+ const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
8682
8818
  try {
8683
- const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
8684
- const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
8685
- if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
8819
+ for (const fd of readdirSync4(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
8686
8820
  } catch {
8687
8821
  }
8822
+ let holds = false;
8823
+ for (const link of procLinks) {
8824
+ try {
8825
+ const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
8826
+ const held = raw.startsWith("/") ? resolve5(raw) : raw;
8827
+ if (held === real || held.startsWith(real + "/")) {
8828
+ holds = true;
8829
+ break;
8830
+ }
8831
+ } catch {
8832
+ }
8833
+ }
8834
+ if (holds) out2.push(n);
8688
8835
  }
8689
8836
  return out2;
8690
8837
  }
@@ -8758,7 +8905,8 @@ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEME
8758
8905
  tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
8759
8906
  return 1;
8760
8907
  }
8761
- const fpBefore = treeFingerprint(cwd);
8908
+ const protectedIgnored = (rel) => isProtected(rel, frozenPolicy);
8909
+ const fpBefore = treeFingerprint(cwd, protectedIgnored);
8762
8910
  let diffCode = 0;
8763
8911
  if (head !== base) {
8764
8912
  out(`
@@ -8775,7 +8923,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
8775
8923
  } catch {
8776
8924
  }
8777
8925
  }
8778
- const mutatedDuringAdjudication = treeFingerprint(cwd) !== fpBefore;
8926
+ const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
8779
8927
  if (mutatedDuringAdjudication) {
8780
8928
  err("tamperward run: the working tree changed while it was being adjudicated \u2014");
8781
8929
  err("the verdict below would describe a tree that no longer exists.");
@@ -8796,7 +8944,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
8796
8944
  err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
8797
8945
  }
8798
8946
  const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
8799
- const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
8947
+ const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
8800
8948
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
8801
8949
  const enforcement = cannot ? 2 : blocked ? 1 : 0;
8802
8950
  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";