tamperward 1.12.0 → 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 +34 -5
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -1660,17 +1660,28 @@ function applyLocalSignoffs(findings, cwd, policy, now = Date.now()) {
1660
1660
  }
1661
1661
  return { findings: remaining, cleared };
1662
1662
  }
1663
- function applyOobSignoffs(findings, oob) {
1664
- 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
+ });
1665
1672
  const cleared = [];
1666
1673
  const remaining = [];
1667
1674
  for (const f of findings) {
1668
- 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));
1669
1676
  if (ok) cleared.push(f);
1670
1677
  else remaining.push(f);
1671
1678
  }
1672
1679
  return { findings: remaining, cleared };
1673
1680
  }
1681
+ function oobHeadFromEnv(env = process.env) {
1682
+ const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
1683
+ return v.length >= 7 ? v : void 0;
1684
+ }
1674
1685
  function oobFromEnv(env = process.env) {
1675
1686
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1676
1687
  }
@@ -1938,7 +1949,7 @@ function check(opts) {
1938
1949
  }
1939
1950
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
1940
1951
  let findings = evaluate(changes, policy, void 0, view);
1941
- 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());
1942
1953
  findings = remaining;
1943
1954
  if (cleared.length) {
1944
1955
  const how = layer === "local" ? "local human sign-off (ledger)" : "out-of-band approval";
@@ -2694,6 +2705,10 @@ jobs:
2694
2705
  - name: Tamperward gate (diff-time)
2695
2706
  env:
2696
2707
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
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 }}
2697
2712
  run: npx --yes tamperward@${TW_VERSION} check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2698
2713
  # Diff-time detection is spelling-dependent by nature; pristine
2699
2714
  # re-execution is not, and it was the layer nothing got past in the
@@ -2704,7 +2719,7 @@ jobs:
2704
2719
  # Requires a \`verify:\` block in .tamperward.yml naming the suite command;
2705
2720
  # without one this step fails closed (exit 2) rather than passing quietly.
2706
2721
  - name: Tamperward verify (pristine re-execution)
2707
- run: npx --yes tamperward@${TW_VERSION} verify --base "\${{ github.event.pull_request.base.sha }}"
2722
+ run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
2708
2723
  `;
2709
2724
  function planPolicy(cwd) {
2710
2725
  const path = join8(cwd, POLICY_FILE);
@@ -2853,6 +2868,10 @@ function resolveBase(base, cwd) {
2853
2868
  return rev;
2854
2869
  }
2855
2870
  }
2871
+ function baseIsAncestorOfHead(base, cwd) {
2872
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
2873
+ return r.status === 0;
2874
+ }
2856
2875
  function materialize(cwd, dest) {
2857
2876
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2858
2877
  for (const rel of listed) {
@@ -2926,6 +2945,15 @@ function runVerify(opts) {
2926
2945
  out2(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
2927
2946
  return 2;
2928
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
+ }
2929
2957
  const cmd = opts.cmd ?? policy.verify?.command;
2930
2958
  if (!cmd) {
2931
2959
  out2("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
@@ -3008,6 +3036,7 @@ function parseVerify(args) {
3008
3036
  else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
3009
3037
  else if (a === "--json") o.json = true;
3010
3038
  else if (a === "--keep") o.keep = true;
3039
+ else if (a === "--require-ancestor") o.requireAncestor = true;
3011
3040
  else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
3012
3041
  }
3013
3042
  return o;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.12.0",
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",