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