tamperward 1.2.1 → 1.4.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/README.md +6 -1
- package/dist/cli/index.js +239 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ with the same engine it ships.
|
|
|
102
102
|
- `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
|
|
103
103
|
- `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
|
|
104
104
|
exit 1 on any blocking finding.
|
|
105
|
-
- `test/` —
|
|
105
|
+
- `test/` — 259 tests, including the AST-vs-regex, self-hosting precision, and
|
|
106
106
|
pre-go-live audit regression cases, and the renderer accessibility contract.
|
|
107
107
|
|
|
108
108
|
- `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
|
|
@@ -119,6 +119,11 @@ enforcement-point wiring, and the proof harness.
|
|
|
119
119
|
## Use
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
|
+
npx tamperward init # wire all four enforcement points in one
|
|
123
|
+
# command: policy file, Claude Code hooks,
|
|
124
|
+
# pre-commit, CI. Idempotent; --dry-run to
|
|
125
|
+
# preview; never overwrites your files.
|
|
126
|
+
|
|
122
127
|
npx tamperward check --staged # pre-commit view
|
|
123
128
|
npx tamperward check --diff "main...HEAD" # CI view — the authority for main
|
|
124
129
|
```
|
package/dist/cli/index.js
CHANGED
|
@@ -371,6 +371,16 @@ function removedLines(c) {
|
|
|
371
371
|
// src/policy.ts
|
|
372
372
|
import picomatch from "picomatch";
|
|
373
373
|
var POLICY_FILE = ".tamperward.yml";
|
|
374
|
+
var BLOCK_SINCE = {};
|
|
375
|
+
function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
376
|
+
const out = { ...rules };
|
|
377
|
+
for (const [rule, since] of Object.entries(gates)) {
|
|
378
|
+
if (since > version && out[rule]?.severity === "block") {
|
|
379
|
+
out[rule] = { ...out[rule], severity: "warn" };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
374
384
|
var cache = /* @__PURE__ */ new Map();
|
|
375
385
|
function matcher(glob) {
|
|
376
386
|
let m = cache.get(glob);
|
|
@@ -407,9 +417,9 @@ function mergeProtected(base, user) {
|
|
|
407
417
|
}
|
|
408
418
|
return out;
|
|
409
419
|
}
|
|
410
|
-
function defaultPolicy() {
|
|
411
|
-
|
|
412
|
-
version
|
|
420
|
+
function defaultPolicy(version = 1) {
|
|
421
|
+
const p = {
|
|
422
|
+
version,
|
|
413
423
|
protected: {
|
|
414
424
|
// Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
|
|
415
425
|
// .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
|
|
@@ -454,6 +464,8 @@ function defaultPolicy() {
|
|
|
454
464
|
ignore: [],
|
|
455
465
|
signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
|
|
456
466
|
};
|
|
467
|
+
p.rules = applyVersionGates(p.rules, version);
|
|
468
|
+
return p;
|
|
457
469
|
}
|
|
458
470
|
|
|
459
471
|
// src/detectors/files.ts
|
|
@@ -924,8 +936,11 @@ function safeParse(src) {
|
|
|
924
936
|
}
|
|
925
937
|
}
|
|
926
938
|
function effective(raw) {
|
|
927
|
-
const
|
|
939
|
+
const v = raw.version;
|
|
940
|
+
const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
|
|
941
|
+
const base = defaultPolicy(version);
|
|
928
942
|
return {
|
|
943
|
+
version,
|
|
929
944
|
rules: { ...base.rules, ...raw.rules ?? {} },
|
|
930
945
|
ignore: raw.ignore ?? base.ignore ?? [],
|
|
931
946
|
protected: mergeProtected(base.protected, raw.protected),
|
|
@@ -940,6 +955,11 @@ function policyWeakening(before, after) {
|
|
|
940
955
|
const be = effective(b);
|
|
941
956
|
const ae = effective(a);
|
|
942
957
|
const reasons = [];
|
|
958
|
+
if (ae.version < be.version) {
|
|
959
|
+
reasons.push(
|
|
960
|
+
`policy version lowered ${be.version} \u2192 ${ae.version} \u2014 un-opts this repo from rule graduations gated above ${ae.version}`
|
|
961
|
+
);
|
|
962
|
+
}
|
|
943
963
|
for (const name of /* @__PURE__ */ new Set([...Object.keys(be.rules), ...Object.keys(ae.rules)])) {
|
|
944
964
|
const br = be.rules[name];
|
|
945
965
|
const ar = ae.rules[name];
|
|
@@ -1349,11 +1369,19 @@ import { join as join2 } from "node:path";
|
|
|
1349
1369
|
import { parse as parse2 } from "yaml";
|
|
1350
1370
|
var PolicyError = class extends Error {
|
|
1351
1371
|
};
|
|
1372
|
+
function normalizeVersion(v, where = POLICY_FILE) {
|
|
1373
|
+
if (v === void 0 || v === null) return 1;
|
|
1374
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1) {
|
|
1375
|
+
throw new PolicyError(`${where}: version must be a positive integer, got ${JSON.stringify(v)}`);
|
|
1376
|
+
}
|
|
1377
|
+
return v;
|
|
1378
|
+
}
|
|
1352
1379
|
function parsePolicy(raw) {
|
|
1353
|
-
const base = defaultPolicy();
|
|
1354
1380
|
const r = raw ?? {};
|
|
1381
|
+
const version = normalizeVersion(r.version);
|
|
1382
|
+
const base = defaultPolicy(version);
|
|
1355
1383
|
return {
|
|
1356
|
-
version
|
|
1384
|
+
version,
|
|
1357
1385
|
// Merge with the baseline, never replace. For an integrity tool, a config that sets
|
|
1358
1386
|
// one rule's severity must NOT silently drop the other nine — nor may naming one
|
|
1359
1387
|
// protected glob wipe out the rest of its category (see mergeProtected).
|
|
@@ -2021,6 +2049,197 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2021
2049
|
return 0;
|
|
2022
2050
|
}
|
|
2023
2051
|
|
|
2052
|
+
// src/cli/init.ts
|
|
2053
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3, chmodSync } from "node:fs";
|
|
2054
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
2055
|
+
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2056
|
+
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2057
|
+
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2058
|
+
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2059
|
+
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
2060
|
+
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
2061
|
+
# Docs: https://github.com/hexrift/tamperward#readme
|
|
2062
|
+
#
|
|
2063
|
+
# version gates rule GRADUATIONS: a baseline rule promoted warn -> block at policy
|
|
2064
|
+
# version N blocks only when you declare version >= N. Raising it is opting in.
|
|
2065
|
+
version: 1
|
|
2066
|
+
|
|
2067
|
+
# protected: # categories MERGE with the baseline (additive, never replace)
|
|
2068
|
+
# tests: ['e2e/**']
|
|
2069
|
+
# rules: # an explicit severity wins over the baseline in either direction
|
|
2070
|
+
# snapshot-rewrite: { severity: block }
|
|
2071
|
+
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
2072
|
+
`;
|
|
2073
|
+
var WORKFLOW_CONTENT = `name: tamperward
|
|
2074
|
+
|
|
2075
|
+
# The CI authority for main: the same engine as the agent hook and pre-commit, run over
|
|
2076
|
+
# the PR's commit range. A block fails the check and clears ONLY via the out-of-band
|
|
2077
|
+
# label \`tamperward:allow:<rule>\` applied by someone with write access \u2014 never a file
|
|
2078
|
+
# the PR itself can commit.
|
|
2079
|
+
#
|
|
2080
|
+
# labeled/unlabeled re-run the gate because the sign-off is read from the EVENT payload:
|
|
2081
|
+
# a label applied after a failure could otherwise never take effect, and REVOKING a
|
|
2082
|
+
# sign-off must re-block rather than linger green.
|
|
2083
|
+
on:
|
|
2084
|
+
pull_request:
|
|
2085
|
+
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
2086
|
+
|
|
2087
|
+
permissions:
|
|
2088
|
+
contents: read
|
|
2089
|
+
|
|
2090
|
+
jobs:
|
|
2091
|
+
tamperward:
|
|
2092
|
+
runs-on: ubuntu-latest
|
|
2093
|
+
timeout-minutes: 10
|
|
2094
|
+
steps:
|
|
2095
|
+
- uses: actions/checkout@v5
|
|
2096
|
+
with:
|
|
2097
|
+
fetch-depth: 0 # the range diff needs both endpoints
|
|
2098
|
+
- uses: actions/setup-node@v6
|
|
2099
|
+
with:
|
|
2100
|
+
node-version: 22
|
|
2101
|
+
- name: Resolve out-of-band sign-off from PR labels
|
|
2102
|
+
id: oob
|
|
2103
|
+
env:
|
|
2104
|
+
LABELS: \${{ toJSON(github.event.pull_request.labels.*.name) }}
|
|
2105
|
+
run: |
|
|
2106
|
+
RULES="$(printf '%s' "$LABELS" | jq -r '.[] | select(startswith("tamperward:allow:")) | sub("^tamperward:allow:"; "")' | paste -sd, -)"
|
|
2107
|
+
echo "rules=$RULES" >> "$GITHUB_OUTPUT"
|
|
2108
|
+
- name: Tamperward gate
|
|
2109
|
+
env:
|
|
2110
|
+
TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
|
|
2111
|
+
run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
2112
|
+
`;
|
|
2113
|
+
function planPolicy(cwd) {
|
|
2114
|
+
const path = join6(cwd, POLICY_FILE);
|
|
2115
|
+
if (existsSync5(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
2116
|
+
return {
|
|
2117
|
+
item: "policy",
|
|
2118
|
+
path: POLICY_FILE,
|
|
2119
|
+
status: "create",
|
|
2120
|
+
detail: "baseline policy with commented overrides",
|
|
2121
|
+
apply: () => writeFileSync3(path, POLICY_CONTENT)
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
function planClaudeHooks(cwd) {
|
|
2125
|
+
const rel = ".claude/settings.json";
|
|
2126
|
+
const path = join6(cwd, rel);
|
|
2127
|
+
let settings = {};
|
|
2128
|
+
if (existsSync5(path)) {
|
|
2129
|
+
let parsed;
|
|
2130
|
+
try {
|
|
2131
|
+
parsed = JSON.parse(readFileSync7(path, "utf8"));
|
|
2132
|
+
} catch {
|
|
2133
|
+
return {
|
|
2134
|
+
item: "agent",
|
|
2135
|
+
path: rel,
|
|
2136
|
+
status: "error",
|
|
2137
|
+
detail: "exists but is not valid JSON \u2014 fix it, then re-run init (refusing to overwrite)"
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2141
|
+
return { item: "agent", path: rel, status: "error", detail: "exists but is not a JSON object \u2014 refusing to overwrite" };
|
|
2142
|
+
}
|
|
2143
|
+
settings = parsed;
|
|
2144
|
+
}
|
|
2145
|
+
const hooks = settings.hooks ??= {};
|
|
2146
|
+
const has = (arr, needle) => (arr ?? []).some((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
|
|
2147
|
+
const needPre = !has(hooks.PreToolUse, "tamperward hook claude");
|
|
2148
|
+
const needStop = !has(hooks.Stop, "tamperward sweep claude");
|
|
2149
|
+
if (!needPre && !needStop) return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2150
|
+
return {
|
|
2151
|
+
item: "agent",
|
|
2152
|
+
path: rel,
|
|
2153
|
+
status: existsSync5(path) ? "update" : "create",
|
|
2154
|
+
detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
|
|
2155
|
+
apply: () => {
|
|
2156
|
+
if (needPre) {
|
|
2157
|
+
(hooks.PreToolUse ??= []).push({
|
|
2158
|
+
matcher: "Bash|Edit|Write|MultiEdit",
|
|
2159
|
+
hooks: [{ type: "command", command: HOOK_CMD }]
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
if (needStop) {
|
|
2163
|
+
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2164
|
+
}
|
|
2165
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2166
|
+
writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n");
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
function planPreCommit(cwd) {
|
|
2171
|
+
const line = `${MARKER}
|
|
2172
|
+
${PRECOMMIT_CMD}
|
|
2173
|
+
`;
|
|
2174
|
+
const husky = join6(cwd, ".husky");
|
|
2175
|
+
const gitDir2 = join6(cwd, ".git");
|
|
2176
|
+
const target = existsSync5(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync5(gitDir2) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
|
|
2177
|
+
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2178
|
+
const path = join6(cwd, target.rel);
|
|
2179
|
+
const existing = existsSync5(path) ? readFileSync7(path, "utf8") : null;
|
|
2180
|
+
if (existing?.includes("tamperward check --staged")) {
|
|
2181
|
+
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
2182
|
+
}
|
|
2183
|
+
return {
|
|
2184
|
+
item: "pre-commit",
|
|
2185
|
+
path: target.rel,
|
|
2186
|
+
status: existing === null ? "create" : "update",
|
|
2187
|
+
detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
|
|
2188
|
+
apply: () => {
|
|
2189
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2190
|
+
const content = existing === null ? `#!/bin/sh
|
|
2191
|
+
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
2192
|
+
writeFileSync3(path, content);
|
|
2193
|
+
chmodSync(path, 493);
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function planWorkflow(cwd) {
|
|
2198
|
+
const rel = ".github/workflows/tamperward.yml";
|
|
2199
|
+
const path = join6(cwd, rel);
|
|
2200
|
+
if (existsSync5(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
|
|
2201
|
+
return {
|
|
2202
|
+
item: "ci",
|
|
2203
|
+
path: rel,
|
|
2204
|
+
status: "create",
|
|
2205
|
+
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2206
|
+
apply: () => {
|
|
2207
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2208
|
+
writeFileSync3(path, WORKFLOW_CONTENT);
|
|
2209
|
+
}
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
function planInit(cwd) {
|
|
2213
|
+
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
|
|
2214
|
+
}
|
|
2215
|
+
function runInit(opts) {
|
|
2216
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2217
|
+
const plan = planInit(cwd);
|
|
2218
|
+
const w = process.stdout;
|
|
2219
|
+
for (const a of plan) {
|
|
2220
|
+
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
2221
|
+
w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
|
|
2222
|
+
`);
|
|
2223
|
+
if (!opts.dryRun && a.apply) a.apply();
|
|
2224
|
+
}
|
|
2225
|
+
const errors = plan.filter((a) => a.status === "error");
|
|
2226
|
+
const changed = plan.filter((a) => a.apply).length;
|
|
2227
|
+
if (errors.length) {
|
|
2228
|
+
w.write(`
|
|
2229
|
+
tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
|
|
2230
|
+
`);
|
|
2231
|
+
return 2;
|
|
2232
|
+
}
|
|
2233
|
+
w.write(
|
|
2234
|
+
changed === 0 ? "\ntamperward init: everything already wired \u2014 nothing to do.\n" : opts.dryRun ? `
|
|
2235
|
+
tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply.
|
|
2236
|
+
` : `
|
|
2237
|
+
tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
|
|
2238
|
+
`
|
|
2239
|
+
);
|
|
2240
|
+
return 0;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2024
2243
|
// src/cli/index.ts
|
|
2025
2244
|
function parseAllow(args) {
|
|
2026
2245
|
const o = {};
|
|
@@ -2042,6 +2261,15 @@ function runAgentCommand(kind, args) {
|
|
|
2042
2261
|
}
|
|
2043
2262
|
return kind === "hook" ? runHookClaude() : runSweepClaude();
|
|
2044
2263
|
}
|
|
2264
|
+
function parseInit(args) {
|
|
2265
|
+
const o = {};
|
|
2266
|
+
for (let i = 0; i < args.length; i++) {
|
|
2267
|
+
const a = args[i];
|
|
2268
|
+
if (a === "--cwd") o.cwd = args[++i];
|
|
2269
|
+
else if (a === "--dry-run") o.dryRun = true;
|
|
2270
|
+
}
|
|
2271
|
+
return o;
|
|
2272
|
+
}
|
|
2045
2273
|
function parseCheck(args) {
|
|
2046
2274
|
const o = {};
|
|
2047
2275
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2085,6 +2313,9 @@ Formats:
|
|
|
2085
2313
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
2086
2314
|
tamperward sweep claude Stop sweep (re-scan the turn's working tree)
|
|
2087
2315
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2316
|
+
tamperward init [--dry-run] wire all four enforcement points: policy
|
|
2317
|
+
file, Claude Code hooks, pre-commit, CI.
|
|
2318
|
+
Idempotent; never overwrites your files.
|
|
2088
2319
|
|
|
2089
2320
|
Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
|
|
2090
2321
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
|
@@ -2101,6 +2332,8 @@ function main(argv) {
|
|
|
2101
2332
|
return runAgentCommand("sweep", rest);
|
|
2102
2333
|
case "allow":
|
|
2103
2334
|
return runAllow(parseAllow(rest));
|
|
2335
|
+
case "init":
|
|
2336
|
+
return runInit(parseInit(rest));
|
|
2104
2337
|
case void 0:
|
|
2105
2338
|
case "-h":
|
|
2106
2339
|
case "--help":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|