tamperward 1.12.0 → 1.14.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 +83 -10
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -215,9 +215,13 @@ function enrich(c, beforeReader, afterReader) {
215
215
  const after = c.op !== "delete" ? afterReader(c.path) : null;
216
216
  return { ...c, before, after };
217
217
  }
218
+ function assertRev(rev) {
219
+ if (rev.startsWith("-")) throw new Error(`refusing a revision that git would read as an option: ${rev}`);
220
+ return rev;
221
+ }
218
222
  function mergeBaseOf(base, head, opts = {}) {
219
223
  try {
220
- return git(["merge-base", base, head], opts.cwd).trim() || base;
224
+ return git(["merge-base", assertRev(base), assertRev(head)], opts.cwd).trim() || base;
221
225
  } catch {
222
226
  return base;
223
227
  }
@@ -254,6 +258,8 @@ function diffSince(rev, opts = {}) {
254
258
  );
255
259
  }
256
260
  function diffRange(base, head, opts = {}) {
261
+ assertRev(base);
262
+ assertRev(head);
257
263
  const raw = git(["diff", "--no-color", "-M", `${base}...${head}`], opts.cwd);
258
264
  const mergeBase = mergeBaseOf(base, head, opts);
259
265
  return parseDiff(raw).map(
@@ -447,7 +453,36 @@ function defaultPolicy(version = 1) {
447
453
  // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
448
454
  // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
449
455
  // strengthening, not a narrowing. picomatch expands the brace.
450
- tests: ["**/*.test.ts", "**/*.spec.ts", "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}", "**/__tests__/**"],
456
+ // test-skip, test-deletion and test-content-removal all gate on this
457
+ // class, so a repo it does not match gets rules that can NEVER fire —
458
+ // protection that is silently absent rather than visibly off. The JS/TS
459
+ // list shipped alone; these are the conventional layouts of the other
460
+ // ecosystems `init` is run in. (P2-13, external review.)
461
+ tests: [
462
+ // JavaScript / TypeScript
463
+ "**/*.test.ts",
464
+ "**/*.spec.ts",
465
+ "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
466
+ "**/__tests__/**",
467
+ // Python (pytest / unittest layouts)
468
+ "**/test_*.py",
469
+ "**/*_test.py",
470
+ "**/conftest.py",
471
+ // Go
472
+ "**/*_test.go",
473
+ // Rust (integration tests live in tests/, unit tests inline)
474
+ "**/tests/**/*.rs",
475
+ // Ruby (RSpec / minitest)
476
+ "**/*_spec.rb",
477
+ "**/*_test.rb",
478
+ // Java / Kotlin / Scala (Maven & Gradle convention)
479
+ "**/src/test/**",
480
+ // PHP (PHPUnit)
481
+ "**/*Test.php",
482
+ // C# (.NET)
483
+ "**/*Tests.cs",
484
+ "**/*Test.cs"
485
+ ],
451
486
  config: [
452
487
  "**/jest.config.*",
453
488
  "**/vitest.config.*",
@@ -1660,17 +1695,28 @@ function applyLocalSignoffs(findings, cwd, policy, now = Date.now()) {
1660
1695
  }
1661
1696
  return { findings: remaining, cleared };
1662
1697
  }
1663
- function applyOobSignoffs(findings, oob) {
1664
- const set = new Set(oob.map((s) => s.trim()).filter(Boolean));
1698
+ function applyOobSignoffs(findings, oob, head) {
1699
+ const tokens2 = oob.map((s) => s.trim()).filter(Boolean);
1700
+ const matches = (want) => tokens2.some((t) => {
1701
+ const at = t.lastIndexOf("@");
1702
+ if (at === -1) return head ? false : t === want;
1703
+ const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
1704
+ if (rule !== want) return false;
1705
+ return head ? head.startsWith(sha2) && sha2.length >= 7 : true;
1706
+ });
1665
1707
  const cleared = [];
1666
1708
  const remaining = [];
1667
1709
  for (const f of findings) {
1668
- const ok = f.severity === "block" && (set.has(f.rule) || (f.file ? set.has(`${f.rule}:${f.file}`) : false));
1710
+ const ok = f.severity === "block" && (matches(f.rule) || (f.file ? matches(`${f.rule}:${f.file}`) : false));
1669
1711
  if (ok) cleared.push(f);
1670
1712
  else remaining.push(f);
1671
1713
  }
1672
1714
  return { findings: remaining, cleared };
1673
1715
  }
1716
+ function oobHeadFromEnv(env = process.env) {
1717
+ const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
1718
+ return v.length >= 7 ? v : void 0;
1719
+ }
1674
1720
  function oobFromEnv(env = process.env) {
1675
1721
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1676
1722
  }
@@ -1808,15 +1854,21 @@ function annotations(findings) {
1808
1854
  function cell(s) {
1809
1855
  return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
1810
1856
  }
1857
+ function mdCode(s) {
1858
+ return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
1859
+ }
1860
+ function mdUrl(s) {
1861
+ return s.replace(/[()<> \r\n"'`\\]/g, (c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase());
1862
+ }
1811
1863
  function locationCell(f, env) {
1812
1864
  if (!f.file) return "`(command)`";
1813
1865
  const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1814
1866
  const server = env.GITHUB_SERVER_URL;
1815
1867
  const repo = env.GITHUB_REPOSITORY;
1816
1868
  const sha2 = env.GITHUB_SHA;
1817
- if (!server || !repo || !sha2) return `\`${cell(label)}\``;
1869
+ if (!server || !repo || !sha2) return `\`${cell(mdCode(label))}\``;
1818
1870
  const frag = f.line ? `#L${f.line}` : "";
1819
- return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
1871
+ return `[\`${cell(mdCode(label))}\`](${server}/${repo}/blob/${sha2}/${mdUrl(f.file)}${frag})`;
1820
1872
  }
1821
1873
  function renderSummary(input, env = process.env) {
1822
1874
  const findings = orderFindings(input.findings);
@@ -1938,7 +1990,7 @@ function check(opts) {
1938
1990
  }
1939
1991
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
1940
1992
  let findings = evaluate(changes, policy, void 0, view);
1941
- const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv());
1993
+ const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
1942
1994
  findings = remaining;
1943
1995
  if (cleared.length) {
1944
1996
  const how = layer === "local" ? "local human sign-off (ledger)" : "out-of-band approval";
@@ -2694,6 +2746,10 @@ jobs:
2694
2746
  - name: Tamperward gate (diff-time)
2695
2747
  env:
2696
2748
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
2749
+ # Binds each approval to the commit it was granted for: labels persist
2750
+ # across pushes, so an unbound one would clear every later finding on
2751
+ # the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
2752
+ TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
2697
2753
  run: npx --yes tamperward@${TW_VERSION} check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2698
2754
  # Diff-time detection is spelling-dependent by nature; pristine
2699
2755
  # re-execution is not, and it was the layer nothing got past in the
@@ -2704,7 +2760,7 @@ jobs:
2704
2760
  # Requires a \`verify:\` block in .tamperward.yml naming the suite command;
2705
2761
  # without one this step fails closed (exit 2) rather than passing quietly.
2706
2762
  - name: Tamperward verify (pristine re-execution)
2707
- run: npx --yes tamperward@${TW_VERSION} verify --base "\${{ github.event.pull_request.base.sha }}"
2763
+ run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
2708
2764
  `;
2709
2765
  function planPolicy(cwd) {
2710
2766
  const path = join8(cwd, POLICY_FILE);
@@ -2751,7 +2807,9 @@ function planClaudeHooks(cwd) {
2751
2807
  apply: () => {
2752
2808
  if (needPre) {
2753
2809
  (hooks.PreToolUse ??= []).push({
2754
- matcher: "Bash|Edit|Write|MultiEdit",
2810
+ // NotebookEdit is modelled by the adapter but was absent here, so
2811
+ // that branch was unreachable in the wiring we install. (P2-12.)
2812
+ matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit",
2755
2813
  hooks: [{ type: "command", command: HOOK_CMD }]
2756
2814
  });
2757
2815
  }
@@ -2846,6 +2904,7 @@ function git2(args, cwd) {
2846
2904
  return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
2847
2905
  }
2848
2906
  function resolveBase(base, cwd) {
2907
+ assertRev(base);
2849
2908
  const rev = git2(["rev-parse", "--verify", `${base}^{commit}`], cwd).trim();
2850
2909
  try {
2851
2910
  return git2(["merge-base", rev, "HEAD"], cwd).trim();
@@ -2853,6 +2912,10 @@ function resolveBase(base, cwd) {
2853
2912
  return rev;
2854
2913
  }
2855
2914
  }
2915
+ function baseIsAncestorOfHead(base, cwd) {
2916
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
2917
+ return r.status === 0;
2918
+ }
2856
2919
  function materialize(cwd, dest) {
2857
2920
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2858
2921
  for (const rel of listed) {
@@ -2926,6 +2989,15 @@ function runVerify(opts) {
2926
2989
  out2(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
2927
2990
  return 2;
2928
2991
  }
2992
+ if (opts.requireAncestor) {
2993
+ const requested = git2(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
2994
+ if (!baseIsAncestorOfHead(requested, cwd)) {
2995
+ out2(
2996
+ `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.`
2997
+ );
2998
+ return 2;
2999
+ }
3000
+ }
2929
3001
  const cmd = opts.cmd ?? policy.verify?.command;
2930
3002
  if (!cmd) {
2931
3003
  out2("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
@@ -3008,6 +3080,7 @@ function parseVerify(args) {
3008
3080
  else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
3009
3081
  else if (a === "--json") o.json = true;
3010
3082
  else if (a === "--keep") o.keep = true;
3083
+ else if (a === "--require-ancestor") o.requireAncestor = true;
3011
3084
  else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
3012
3085
  }
3013
3086
  return o;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.12.0",
3
+ "version": "1.14.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",