tamperward 1.11.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +105 -15
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -463,7 +463,20 @@ function defaultPolicy(version = 1) {
463
463
  // tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
464
464
  // live there as .cjs and were rewritten unseen (PHASE3.md §4).
465
465
  snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
466
- hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
466
+ // The wiring IS the enforcement: settings.json carries the PreToolUse and
467
+ // Stop hooks, and a pre-commit config carries the commit-time gate. These
468
+ // were installed by `init` and protected by nothing, so editing them out
469
+ // fired no rule at all. (.git/hooks/** is outside every git view and so
470
+ // cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
471
+ hooks: [
472
+ ".husky/**",
473
+ "**/lefthook.*",
474
+ ".tamperward.yml",
475
+ "**/.tamperward.yml",
476
+ ".claude/settings.json",
477
+ ".claude/settings.local.json",
478
+ "**/.pre-commit-config.yaml"
479
+ ]
467
480
  },
468
481
  rules: {
469
482
  "test-deletion": { severity: "block" },
@@ -1516,8 +1529,18 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1516
1529
  try {
1517
1530
  out2.push(...d.run(active, policy, view));
1518
1531
  } catch (e) {
1519
- process.stderr.write(`tamperward: detector "${d.id}" errored and was skipped: ${String(e)}
1532
+ process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
1520
1533
  `);
1534
+ if (view === "staged" || view === "worktree" || view === "range") {
1535
+ out2.push({
1536
+ rule: "detector-error",
1537
+ severity: "block",
1538
+ message: `Detector "${d.id}" failed to run; the verdict is incomplete.`,
1539
+ evidence: String(e).slice(0, 200),
1540
+ remediation: "A rule that cannot run is not a rule that passed. Fix the input or the detector; do not read this as clean.",
1541
+ signoff: { required: true, command: `tamperward allow detector-error --reason "..."` }
1542
+ });
1543
+ }
1521
1544
  }
1522
1545
  }
1523
1546
  const seen = /* @__PURE__ */ new Set();
@@ -1637,17 +1660,28 @@ function applyLocalSignoffs(findings, cwd, policy, now = Date.now()) {
1637
1660
  }
1638
1661
  return { findings: remaining, cleared };
1639
1662
  }
1640
- function applyOobSignoffs(findings, oob) {
1641
- const set = new Set(oob.map((s) => s.trim()).filter(Boolean));
1663
+ function applyOobSignoffs(findings, oob, head) {
1664
+ const tokens2 = oob.map((s) => s.trim()).filter(Boolean);
1665
+ const matches = (want) => tokens2.some((t) => {
1666
+ const at = t.lastIndexOf("@");
1667
+ if (at === -1) return head ? false : t === want;
1668
+ const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
1669
+ if (rule !== want) return false;
1670
+ return head ? head.startsWith(sha2) && sha2.length >= 7 : true;
1671
+ });
1642
1672
  const cleared = [];
1643
1673
  const remaining = [];
1644
1674
  for (const f of findings) {
1645
- const ok = f.severity === "block" && (set.has(f.rule) || (f.file ? set.has(`${f.rule}:${f.file}`) : false));
1675
+ const ok = f.severity === "block" && (matches(f.rule) || (f.file ? matches(`${f.rule}:${f.file}`) : false));
1646
1676
  if (ok) cleared.push(f);
1647
1677
  else remaining.push(f);
1648
1678
  }
1649
1679
  return { findings: remaining, cleared };
1650
1680
  }
1681
+ function oobHeadFromEnv(env = process.env) {
1682
+ const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
1683
+ return v.length >= 7 ? v : void 0;
1684
+ }
1651
1685
  function oobFromEnv(env = process.env) {
1652
1686
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1653
1687
  }
@@ -1915,7 +1949,7 @@ function check(opts) {
1915
1949
  }
1916
1950
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
1917
1951
  let findings = evaluate(changes, policy, void 0, view);
1918
- const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv());
1952
+ const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
1919
1953
  findings = remaining;
1920
1954
  if (cleared.length) {
1921
1955
  const how = layer === "local" ? "local human sign-off (ledger)" : "out-of-band approval";
@@ -2599,6 +2633,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2599
2633
  // src/cli/init.ts
2600
2634
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2601
2635
  import { dirname as dirname3, join as join8 } from "node:path";
2636
+ import { fileURLToPath } from "node:url";
2602
2637
  var HOOK_CMD = "npx --yes tamperward hook claude";
2603
2638
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2604
2639
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
@@ -2617,6 +2652,21 @@ version: 1
2617
2652
  # snapshot-rewrite: { severity: block }
2618
2653
  # ignore: [] # visible blind spots \u2014 the count is always reported
2619
2654
  `;
2655
+ function shippedVersion() {
2656
+ try {
2657
+ const here = dirname3(fileURLToPath(import.meta.url));
2658
+ for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2659
+ try {
2660
+ const pkg = JSON.parse(readFileSync10(join8(here, rel), "utf8"));
2661
+ if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2662
+ } catch {
2663
+ }
2664
+ }
2665
+ } catch {
2666
+ }
2667
+ return "latest";
2668
+ }
2669
+ var TW_VERSION = shippedVersion();
2620
2670
  var WORKFLOW_CONTENT = `name: tamperward
2621
2671
 
2622
2672
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
@@ -2652,10 +2702,24 @@ jobs:
2652
2702
  run: |
2653
2703
  RULES="$(printf '%s' "$LABELS" | jq -r '.[] | select(startswith("tamperward:allow:")) | sub("^tamperward:allow:"; "")' | paste -sd, -)"
2654
2704
  echo "rules=$RULES" >> "$GITHUB_OUTPUT"
2655
- - name: Tamperward gate
2705
+ - name: Tamperward gate (diff-time)
2656
2706
  env:
2657
2707
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
2658
- run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2708
+ # Binds each approval to the commit it was granted for: labels persist
2709
+ # across pushes, so an unbound one would clear every later finding on
2710
+ # the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
2711
+ TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
2712
+ run: npx --yes tamperward@${TW_VERSION} check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2713
+ # Diff-time detection is spelling-dependent by nature; pristine
2714
+ # re-execution is not, and it was the layer nothing got past in the
2715
+ # taskbench sweeps. Shipping a CI template WITHOUT it meant every
2716
+ # masked-failure class was adjudicated only at the local layer \u2014 the
2717
+ # layer an agent shares a filesystem with. (P1-5, external review.)
2718
+ #
2719
+ # Requires a \`verify:\` block in .tamperward.yml naming the suite command;
2720
+ # without one this step fails closed (exit 2) rather than passing quietly.
2721
+ - name: Tamperward verify (pristine re-execution)
2722
+ run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
2659
2723
  `;
2660
2724
  function planPolicy(cwd) {
2661
2725
  const path = join8(cwd, POLICY_FILE);
@@ -2789,7 +2853,7 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
2789
2853
 
2790
2854
  // src/cli/verify.ts
2791
2855
  import { execFileSync as execFileSync4, spawnSync } from "node:child_process";
2792
- import { chmodSync as chmodSync2, cpSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
2856
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
2793
2857
  import { tmpdir as tmpdir2 } from "node:os";
2794
2858
  import { dirname as dirname4, join as join9, resolve } from "node:path";
2795
2859
  var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
@@ -2804,6 +2868,10 @@ function resolveBase(base, cwd) {
2804
2868
  return rev;
2805
2869
  }
2806
2870
  }
2871
+ function baseIsAncestorOfHead(base, cwd) {
2872
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
2873
+ return r.status === 0;
2874
+ }
2807
2875
  function materialize(cwd, dest) {
2808
2876
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2809
2877
  for (const rel of listed) {
@@ -2817,12 +2885,23 @@ function materialize(cwd, dest) {
2817
2885
  if (!st.isFile()) continue;
2818
2886
  const out2 = join9(dest, rel);
2819
2887
  mkdirSync6(dirname4(out2), { recursive: true });
2820
- cpSync(src, out2);
2888
+ dropSymlink(out2);
2889
+ if (lstatSync(src).isSymbolicLink()) {
2890
+ writeFileSync6(out2, readFileSync11(src));
2891
+ } else {
2892
+ cpSync(src, out2, { dereference: true });
2893
+ }
2821
2894
  chmodSync2(out2, st.mode);
2822
2895
  }
2823
2896
  const nm = resolve(cwd, "node_modules");
2824
2897
  if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
2825
2898
  }
2899
+ function dropSymlink(p) {
2900
+ try {
2901
+ if (lstatSync(p).isSymbolicLink()) rmSync2(p, { force: true });
2902
+ } catch {
2903
+ }
2904
+ }
2826
2905
  function overlayPristine(cwd, base, dest, policy) {
2827
2906
  const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
2828
2907
  const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c));
@@ -2834,6 +2913,7 @@ function overlayPristine(cwd, base, dest, policy) {
2834
2913
  const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
2835
2914
  const out2 = join9(dest, rel);
2836
2915
  mkdirSync6(dirname4(out2), { recursive: true });
2916
+ dropSymlink(out2);
2837
2917
  writeFileSync6(out2, content);
2838
2918
  restored++;
2839
2919
  }
@@ -2865,6 +2945,15 @@ function runVerify(opts) {
2865
2945
  out2(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
2866
2946
  return 2;
2867
2947
  }
2948
+ if (opts.requireAncestor) {
2949
+ const requested = git2(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
2950
+ if (!baseIsAncestorOfHead(requested, cwd)) {
2951
+ out2(
2952
+ `verify: --require-ancestor: ${requested.slice(0, 10)} is not an ancestor of HEAD \u2014 the anchor would silently resolve to an older commit. Failing closed.`
2953
+ );
2954
+ return 2;
2955
+ }
2956
+ }
2868
2957
  const cmd = opts.cmd ?? policy.verify?.command;
2869
2958
  if (!cmd) {
2870
2959
  out2("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
@@ -2947,6 +3036,7 @@ function parseVerify(args) {
2947
3036
  else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
2948
3037
  else if (a === "--json") o.json = true;
2949
3038
  else if (a === "--keep") o.keep = true;
3039
+ else if (a === "--require-ancestor") o.requireAncestor = true;
2950
3040
  else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
2951
3041
  }
2952
3042
  return o;
@@ -2955,7 +3045,7 @@ function parseVerify(args) {
2955
3045
  // src/cli/run.ts
2956
3046
  import { execFileSync as execFileSync5, spawnSync as spawnSync2 } from "node:child_process";
2957
3047
  import { createHash as createHash5 } from "node:crypto";
2958
- import { readdirSync as readdirSync3, readFileSync as readFileSync11, readlinkSync, realpathSync, statSync as statSync6 } from "node:fs";
3048
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, realpathSync, statSync as statSync6 } from "node:fs";
2959
3049
  import { join as join10, resolve as resolve2 } from "node:path";
2960
3050
  var out = (s) => process.stdout.write(s + "\n");
2961
3051
  var err = (s) => process.stderr.write(s + "\n");
@@ -2969,7 +3059,7 @@ function treeFingerprint(cwd) {
2969
3059
  h.update(rel);
2970
3060
  h.update("\0");
2971
3061
  try {
2972
- h.update(readFileSync11(join10(cwd, rel)));
3062
+ h.update(readFileSync12(join10(cwd, rel)));
2973
3063
  } catch {
2974
3064
  h.update("<unreadable>");
2975
3065
  }
@@ -2993,7 +3083,7 @@ function depsFingerprint(cwd) {
2993
3083
  else if (e.isFile()) {
2994
3084
  h.update(p);
2995
3085
  try {
2996
- h.update(readFileSync11(p));
3086
+ h.update(readFileSync12(p));
2997
3087
  h.update(String(statSync6(p).mode));
2998
3088
  } catch {
2999
3089
  h.update("<unreadable>");
@@ -3018,7 +3108,7 @@ function depsFingerprint(cwd) {
3018
3108
  }
3019
3109
  function nowTicks() {
3020
3110
  try {
3021
- return Math.floor(parseFloat(readFileSync11("/proc/uptime", "utf8").split(" ")[0]) * 100);
3111
+ return Math.floor(parseFloat(readFileSync12("/proc/uptime", "utf8").split(" ")[0]) * 100);
3022
3112
  } catch {
3023
3113
  return Number.POSITIVE_INFINITY;
3024
3114
  }
@@ -3048,7 +3138,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3048
3138
  }
3049
3139
  if (held !== real && !held.startsWith(real + "/")) continue;
3050
3140
  try {
3051
- const stat = readFileSync11(`/proc/${pid}/stat`, "utf8");
3141
+ const stat = readFileSync12(`/proc/${pid}/stat`, "utf8");
3052
3142
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
3053
3143
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
3054
3144
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",