tamperward 1.3.0 → 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 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/` — 248 tests, including the AST-vs-regex, self-hosting precision, and
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
@@ -2049,6 +2049,197 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2049
2049
  return 0;
2050
2050
  }
2051
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
+
2052
2243
  // src/cli/index.ts
2053
2244
  function parseAllow(args) {
2054
2245
  const o = {};
@@ -2070,6 +2261,15 @@ function runAgentCommand(kind, args) {
2070
2261
  }
2071
2262
  return kind === "hook" ? runHookClaude() : runSweepClaude();
2072
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
+ }
2073
2273
  function parseCheck(args) {
2074
2274
  const o = {};
2075
2275
  for (let i = 0; i < args.length; i++) {
@@ -2113,6 +2313,9 @@ Formats:
2113
2313
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
2114
2314
  tamperward sweep claude Stop sweep (re-scan the turn's working tree)
2115
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.
2116
2319
 
2117
2320
  Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
2118
2321
  emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
@@ -2129,6 +2332,8 @@ function main(argv) {
2129
2332
  return runAgentCommand("sweep", rest);
2130
2333
  case "allow":
2131
2334
  return runAllow(parseAllow(rest));
2335
+ case "init":
2336
+ return runInit(parseInit(rest));
2132
2337
  case void 0:
2133
2338
  case "-h":
2134
2339
  case "--help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.3.0",
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",