tamperward 1.11.0 → 1.12.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 +123 -16
  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();
@@ -2599,6 +2622,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2599
2622
  // src/cli/init.ts
2600
2623
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2601
2624
  import { dirname as dirname3, join as join8 } from "node:path";
2625
+ import { fileURLToPath } from "node:url";
2602
2626
  var HOOK_CMD = "npx --yes tamperward hook claude";
2603
2627
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2604
2628
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
@@ -2617,6 +2641,21 @@ version: 1
2617
2641
  # snapshot-rewrite: { severity: block }
2618
2642
  # ignore: [] # visible blind spots \u2014 the count is always reported
2619
2643
  `;
2644
+ function shippedVersion() {
2645
+ try {
2646
+ const here = dirname3(fileURLToPath(import.meta.url));
2647
+ for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2648
+ try {
2649
+ const pkg = JSON.parse(readFileSync10(join8(here, rel), "utf8"));
2650
+ if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2651
+ } catch {
2652
+ }
2653
+ }
2654
+ } catch {
2655
+ }
2656
+ return "latest";
2657
+ }
2658
+ var TW_VERSION = shippedVersion();
2620
2659
  var WORKFLOW_CONTENT = `name: tamperward
2621
2660
 
2622
2661
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
@@ -2652,10 +2691,20 @@ jobs:
2652
2691
  run: |
2653
2692
  RULES="$(printf '%s' "$LABELS" | jq -r '.[] | select(startswith("tamperward:allow:")) | sub("^tamperward:allow:"; "")' | paste -sd, -)"
2654
2693
  echo "rules=$RULES" >> "$GITHUB_OUTPUT"
2655
- - name: Tamperward gate
2694
+ - name: Tamperward gate (diff-time)
2656
2695
  env:
2657
2696
  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 }}"
2697
+ run: npx --yes tamperward@${TW_VERSION} check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2698
+ # Diff-time detection is spelling-dependent by nature; pristine
2699
+ # re-execution is not, and it was the layer nothing got past in the
2700
+ # taskbench sweeps. Shipping a CI template WITHOUT it meant every
2701
+ # masked-failure class was adjudicated only at the local layer \u2014 the
2702
+ # layer an agent shares a filesystem with. (P1-5, external review.)
2703
+ #
2704
+ # Requires a \`verify:\` block in .tamperward.yml naming the suite command;
2705
+ # without one this step fails closed (exit 2) rather than passing quietly.
2706
+ - name: Tamperward verify (pristine re-execution)
2707
+ run: npx --yes tamperward@${TW_VERSION} verify --base "\${{ github.event.pull_request.base.sha }}"
2659
2708
  `;
2660
2709
  function planPolicy(cwd) {
2661
2710
  const path = join8(cwd, POLICY_FILE);
@@ -2789,9 +2838,9 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
2789
2838
 
2790
2839
  // src/cli/verify.ts
2791
2840
  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";
2841
+ 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
2842
  import { tmpdir as tmpdir2 } from "node:os";
2794
- import { dirname as dirname4, join as join9 } from "node:path";
2843
+ import { dirname as dirname4, join as join9, resolve } from "node:path";
2795
2844
  var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
2796
2845
  function git2(args, cwd) {
2797
2846
  return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
@@ -2817,12 +2866,23 @@ function materialize(cwd, dest) {
2817
2866
  if (!st.isFile()) continue;
2818
2867
  const out2 = join9(dest, rel);
2819
2868
  mkdirSync6(dirname4(out2), { recursive: true });
2820
- cpSync(src, out2);
2869
+ dropSymlink(out2);
2870
+ if (lstatSync(src).isSymbolicLink()) {
2871
+ writeFileSync6(out2, readFileSync11(src));
2872
+ } else {
2873
+ cpSync(src, out2, { dereference: true });
2874
+ }
2821
2875
  chmodSync2(out2, st.mode);
2822
2876
  }
2823
- const nm = join9(cwd, "node_modules");
2877
+ const nm = resolve(cwd, "node_modules");
2824
2878
  if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
2825
2879
  }
2880
+ function dropSymlink(p) {
2881
+ try {
2882
+ if (lstatSync(p).isSymbolicLink()) rmSync2(p, { force: true });
2883
+ } catch {
2884
+ }
2885
+ }
2826
2886
  function overlayPristine(cwd, base, dest, policy) {
2827
2887
  const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
2828
2888
  const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c));
@@ -2834,6 +2894,7 @@ function overlayPristine(cwd, base, dest, policy) {
2834
2894
  const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
2835
2895
  const out2 = join9(dest, rel);
2836
2896
  mkdirSync6(dirname4(out2), { recursive: true });
2897
+ dropSymlink(out2);
2837
2898
  writeFileSync6(out2, content);
2838
2899
  restored++;
2839
2900
  }
@@ -2955,8 +3016,8 @@ function parseVerify(args) {
2955
3016
  // src/cli/run.ts
2956
3017
  import { execFileSync as execFileSync5, spawnSync as spawnSync2 } from "node:child_process";
2957
3018
  import { createHash as createHash5 } from "node:crypto";
2958
- import { readdirSync as readdirSync3, readFileSync as readFileSync11, realpathSync } from "node:fs";
2959
- import { join as join10 } from "node:path";
3019
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, realpathSync, statSync as statSync6 } from "node:fs";
3020
+ import { join as join10, resolve as resolve2 } from "node:path";
2960
3021
  var out = (s) => process.stdout.write(s + "\n");
2961
3022
  var err = (s) => process.stderr.write(s + "\n");
2962
3023
  function git3(args, cwd) {
@@ -2969,7 +3030,7 @@ function treeFingerprint(cwd) {
2969
3030
  h.update(rel);
2970
3031
  h.update("\0");
2971
3032
  try {
2972
- h.update(readFileSync11(join10(cwd, rel)));
3033
+ h.update(readFileSync12(join10(cwd, rel)));
2973
3034
  } catch {
2974
3035
  h.update("<unreadable>");
2975
3036
  }
@@ -2977,9 +3038,48 @@ function treeFingerprint(cwd) {
2977
3038
  }
2978
3039
  return h.digest("hex");
2979
3040
  }
3041
+ function depsFingerprint(cwd) {
3042
+ const root = join10(cwd, "node_modules");
3043
+ const h = createHash5("sha256");
3044
+ const walk = (dir) => {
3045
+ let entries;
3046
+ try {
3047
+ entries = readdirSync3(dir, { withFileTypes: true });
3048
+ } catch {
3049
+ return;
3050
+ }
3051
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3052
+ const p = join10(dir, e.name);
3053
+ if (e.isDirectory()) walk(p);
3054
+ else if (e.isFile()) {
3055
+ h.update(p);
3056
+ try {
3057
+ h.update(readFileSync12(p));
3058
+ h.update(String(statSync6(p).mode));
3059
+ } catch {
3060
+ h.update("<unreadable>");
3061
+ }
3062
+ } else if (e.isSymbolicLink()) {
3063
+ h.update(p);
3064
+ try {
3065
+ h.update(readlinkSync(p));
3066
+ } catch {
3067
+ h.update("<unreadable-link>");
3068
+ }
3069
+ }
3070
+ }
3071
+ };
3072
+ try {
3073
+ if (!statSync6(root).isDirectory()) return "none";
3074
+ } catch {
3075
+ return "none";
3076
+ }
3077
+ walk(root);
3078
+ return h.digest("hex");
3079
+ }
2980
3080
  function nowTicks() {
2981
3081
  try {
2982
- return Math.floor(parseFloat(readFileSync11("/proc/uptime", "utf8").split(" ")[0]) * 100);
3082
+ return Math.floor(parseFloat(readFileSync12("/proc/uptime", "utf8").split(" ")[0]) * 100);
2983
3083
  } catch {
2984
3084
  return Number.POSITIVE_INFINITY;
2985
3085
  }
@@ -3009,7 +3109,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3009
3109
  }
3010
3110
  if (held !== real && !held.startsWith(real + "/")) continue;
3011
3111
  try {
3012
- const stat = readFileSync11(`/proc/${pid}/stat`, "utf8");
3112
+ const stat = readFileSync12(`/proc/${pid}/stat`, "utf8");
3013
3113
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
3014
3114
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
3015
3115
  } catch {
@@ -3018,7 +3118,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3018
3118
  return out2;
3019
3119
  }
3020
3120
  function runEnvelope(opts) {
3021
- const cwd = opts.cwd ?? process.cwd();
3121
+ const cwd = resolve2(opts.cwd ?? process.cwd());
3022
3122
  if (!opts.argv.length) {
3023
3123
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3024
3124
  return 2;
@@ -3055,6 +3155,7 @@ function runEnvelope(opts) {
3055
3155
  }
3056
3156
  }
3057
3157
  out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
3158
+ const depsBefore = depsFingerprint(cwd);
3058
3159
  const spawnTicks = nowTicks();
3059
3160
  const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
3060
3161
  const agentExit = agent.status ?? 1;
@@ -3089,16 +3190,21 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3089
3190
  err("tamperward run: the working tree changed while it was being adjudicated \u2014");
3090
3191
  err("the verdict below would describe a tree that no longer exists.");
3091
3192
  }
3193
+ const depsDrifted = !opts.allowDepDrift && depsFingerprint(cwd) !== depsBefore;
3194
+ if (depsDrifted) {
3195
+ err("tamperward run: the installed dependency tree changed during this run \u2014 the program that");
3196
+ err("runs the suite is not the one we started with. Both verification runs execute it.");
3197
+ }
3092
3198
  const survivors = survivorsHoldingTree(cwd, spawnTicks);
3093
3199
  if (survivors.length) {
3094
3200
  err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
3095
3201
  err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
3096
3202
  }
3097
- const notQuiescent = mutatedDuringAdjudication || survivors.length > 0;
3203
+ const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted;
3098
3204
  const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
3099
3205
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
3100
3206
  const enforcement = cannot ? 2 : blocked ? 1 : 0;
3101
- const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : 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";
3207
+ const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : 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";
3102
3208
  out(`
3103
3209
  tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
3104
3210
  return enforcement !== 0 ? enforcement : agentExit;
@@ -3115,6 +3221,7 @@ function parseRun(args) {
3115
3221
  else if (a === "--budget") o.budget = Number(args[++i]);
3116
3222
  else if (a === "--allow-dirty") o.allowDirty = true;
3117
3223
  else if (a === "--settle") o.settle = Number(args[++i]);
3224
+ else if (a === "--allow-dep-drift") o.allowDepDrift = true;
3118
3225
  else if (a === "--cwd") o.cwd = args[++i];
3119
3226
  else {
3120
3227
  o.argv = args.slice(i);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",