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.
- package/dist/cli/index.js +83 -10
- 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
|
-
|
|
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
|
|
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" && (
|
|
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
|
-
|
|
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.
|
|
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",
|