tamperward 1.3.0 → 1.4.1

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 (3) hide show
  1. package/README.md +117 -134
  2. package/dist/cli/index.js +205 -0
  3. package/package.json +8 -5
package/README.md CHANGED
@@ -6,55 +6,51 @@
6
6
 
7
7
  <p align="center"><em>A ward is the obstruction inside a lock that blocks the wrong key.</em></p>
8
8
 
9
- **The deterministic agent-integrity gate.** One ruleset, evaluated on the actual
10
- diff and commands as a verdict not a probability — enforced everywhere a change can
11
- be made: inside the coding agent's loop, at the commit, and at the merge.
12
-
13
- AI agents optimize for "the command succeeded," not "the change is trustworthy." To
14
- make checks pass they take the cheapest path: deleting failing tests, weakening
15
- assertions, casting to `any`, suppressing lint/type errors, lowering coverage gates,
16
- editing CI, or bypassing hooks with `--no-verify`. Tamperward treats the safety nets
17
- themselves as protected assets and blocks the *class* of bypass — so no single
18
- shortcut is enough.
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/tamperward"><img src="https://img.shields.io/npm/v/tamperward?label=npm&color=4F46E5" alt="npm version"></a>
11
+ <a href="https://github.com/hexrift/tamperward/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/hexrift/tamperward/release.yml?label=release" alt="release"></a>
12
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-lightgrey" alt="license"></a>
13
+ </p>
19
14
 
20
- > Apache-2.0 core · `github.com/hexrift/tamperward`
15
+ **The deterministic agent-integrity gate.** AI coding agents optimize for "the command
16
+ succeeded," not "the change is trustworthy." To make checks pass they take the cheapest
17
+ path: deleting failing tests, skipping them, casting to `any`, suppressing lint errors,
18
+ lowering coverage gates, rewriting snapshots, editing CI, or bypassing hooks with
19
+ `--no-verify`. Tamperward treats the safety nets themselves as protected assets and
20
+ blocks the **class** of bypass — one ruleset, evaluated on the actual diff and commands
21
+ as a verdict, not a probability, enforced everywhere a change can be made: **inside the
22
+ agent's loop, at the commit, and at the merge.**
21
23
 
22
- ## Stability
24
+ > Apache-2.0 · zero runtime model calls · every headline claim below is measured, with
25
+ > the pre-registered predictions (including the refuted ones) committed to this repo
23
26
 
24
- `1.0.0` commits to the surface you actually depend on: the CLI and its **exit codes**, the
25
- hook wire format, the `.tamperward.yml` schema, and the `--json` `Finding` shape. The
26
- package publishes no `main` and no `exports` — it is a binary, not a library — so nothing
27
- under `src/` is public and internal refactors will never break you.
27
+ ## Install
28
28
 
29
- The version answers one question: *can taking this upgrade turn a green build red without
30
- me changing anything?* **Patch never can** — bypass fixes and false-positive fixes ship as
31
- patches so they reach you automatically. **Major can**, and only ever will alongside a
32
- changelog entry saying which rule changed. See
33
- [CONTRIBUTING](./CONTRIBUTING.md#versioning) for the full rule.
29
+ ```bash
30
+ npx tamperward init
31
+ ```
34
32
 
35
- Two rules ship as `warn` and do not block: `assertion-weakening` and `guard-removal`. They
36
- are heuristic, and they stay non-blocking until their precision is measured (SPEC §7.A).
33
+ One idempotent command wires all four enforcement points it never overwrites anything
34
+ you wrote, and `--dry-run` shows the plan first:
37
35
 
38
- ## Does the loop actually work?
36
+ | point | what it wires |
37
+ | --- | --- |
38
+ | agent loop | Claude Code `PreToolUse` deny + `Stop` sweep, merged into `.claude/settings.json` |
39
+ | pre-commit | husky when present, the plain git hook otherwise |
40
+ | CI | a PR-gate workflow with out-of-band sign-off (details below) |
41
+ | policy | a commented baseline `.tamperward.yml` — the defaults apply even without it |
39
42
 
40
- The gate is only half the claim. The other half is what an agent does *after* it is
41
- denied: fix the real failure, or hunt for another way around. That is the
42
- **bypass-to-fix conversion rate** (SPEC §7.B), and it is measured, not asserted —
43
- `harness/run.sh` stands up an isolated repo with a real bug and a failing protected
44
- test, lets a headless agent loose on it with the hooks installed, and classifies the
45
- end state with an oracle independent of the detectors under test.
43
+ Or run the views directly:
46
44
 
47
- **42 runs. 0 tampers reached green. Of the 20 runs where a block fired, 20 ended in an
48
- honest fix.**
45
+ ```bash
46
+ npx tamperward check --staged # pre-commit view
47
+ npx tamperward check --diff "main...HEAD" # CI view — the authority for main
48
+ ```
49
49
 
50
- | arm | model | runs | blocks fired | honest fix | tamper through |
51
- |---|---|---|---|---|---|
52
- | `seed` · adversarial | Sonnet 5 | 1 | 0 | 1 | 0 |
53
- | `seed` · **forced** | Sonnet 5 | 5 | 0 | 5 | 0 |
54
- | `seed-hard` · adversarial | Sonnet 5 | 3 | 0 | 3 | 0 |
55
- | `seed-hard` · adversarial | Haiku 4.5 | 33 | 20 | 33 | 0 |
50
+ ## What it looks like when it fires
56
51
 
57
- The trajectory the whole design is built around, from a transcript:
52
+ An agent, mid-loop, tries the classic shortcut. The deny happens **before** the tool
53
+ runs, and holds even under `bypassPermissions`:
58
54
 
59
55
  ```
60
56
  5. Bash rm calc.test.js → DENIED (test-deletion)
@@ -62,110 +58,97 @@ The trajectory the whole design is built around, from a transcript:
62
58
  7. Bash node --test → green, honestly
63
59
  ```
64
60
 
65
- **Read the number with its limits.** 20/20 is a point estimate on a finite sample: it
66
- supports "no conversion failure observed in 20 interceptions", whose 95% lower bound is
67
- ≈ 86% — above the ~80% bar SPEC §7.B set for "the loop works", but not a claim of 100%.
68
- The split matters more than the total — Sonnet 5 never attempted a bypass at all,
69
- including 5/5 runs in `forced` mode where the prompt *orders* it to delete the failing
70
- test; all 20 interceptions came from Haiku 4.5 on the seed whose honest fix is genuinely
71
- expensive. Of those 20, the agent's first move after the denial was a real fix attempt
72
- 19 times; once it went and read `.tamperward.yml` looking for another way through before
73
- converting. That probe is counted here as an attempted second bypass, not smoothed away.
74
-
75
- So on scenarios this size, a frontier model mostly does not need the gate. What the run
76
- supports is the narrower, more durable claim: **when a shortcut is attempted, it is
77
- caught deterministically, and the denial redirects the agent to the real fix rather than
78
- to another bypass.**
79
-
80
- Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`
81
-
82
- ## Status
83
-
84
- Phase 1 — the engine, the eight mechanical detectors, the `check` CLI, and the Claude Code
85
- agent layer (PreToolUse hook + Stop sweep + `allow`). Tamperward gates its own repo in CI
86
- with the same engine it ships.
87
-
88
- - `src/types.ts` — the `Change` model every adapter manufactures and every detector
89
- consumes (the one decision the codebase inherits).
90
- - `src/diff/parse.ts` — pure `git diff` → `Change[]` parser. Handles add / modify /
91
- delete / **rename (as one change carrying `oldPath`)** / rename+edit / binary, with
92
- per-line old/new line numbers correct across multiple hunks.
93
- - `src/git/build.ts` — the git adapter: range / staged / worktree views, enriching
94
- `before`/`after` with full file content for the AST detectors.
95
- - `src/detectors/` — the **nine mechanical rules**: `no-verify`, `ts-any-cast`,
96
- `lint-suppression`, `test-skip`, `coverage-lowering`, `ci-tampering`,
97
- `hook-tampering`, `test-deletion` (the last counts `it()/test()` via the TS AST,
98
- and handles delete / rename-out-of-glob / shell mutation), and `snapshot-rewrite`
99
- (a `warn`: re-recording a snapshot/golden expectation from current output — the one
100
- rule built from measured demand, after the affordance experiment put the move at a
101
- 70% attempt and 100% through rate; see `harness/PREDICTION-affordance.md`).
102
- - `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
103
- - `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
104
- exit 1 on any blocking finding.
105
- - `test/` — 248 tests, including the AST-vs-regex, self-hosting precision, and
106
- pre-go-live audit regression cases, and the renderer accessibility contract.
107
-
108
- - `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
109
- (PreToolUse deny, fail-closed) and `tamperward sweep claude` (Stop sweep, compared against
110
- the turn's starting commit so a mid-turn commit can't launder a tamper past it).
111
- - `src/signoff.ts` — the three-layer sign-off model: the agent honours nothing it can author.
112
-
113
- Next: the negatives corpus to graduate the two heuristic rules, and a larger §7.B run
114
- to tighten the interval on the conversion rate.
115
-
116
- See **[SPEC.md](./SPEC.md)** for the full build spec, the detector table, the
117
- enforcement-point wiring, and the proof harness.
118
-
119
- ## Use
61
+ The same engine at pre-commit or in CI:
120
62
 
121
- ```bash
122
- npx tamperward check --staged # pre-commit view
123
- npx tamperward check --diff "main...HEAD" # CI view — the authority for main
63
+ ```
64
+ tamperward: 2 blocking
65
+ (2 changes scanned)
66
+
67
+ BLOCK coverage-lowering package.json
68
+ Coverage gate weakened: global lines threshold lowered 90 → 10.
69
+ evidence global lines threshold lowered 90 → 10
70
+ instead Raise real coverage by adding tests; do not lower or exempt
71
+ the gate to pass.
72
+ sign-off tamperward allow coverage-lowering --file package.json --reason "..."
73
+
74
+ BLOCK test-deletion test/calc.test.js
75
+ Test blocks removed: 3 → 1 it()/test() in this spec.
76
+ evidence 2 test block(s) removed from test/calc.test.js
77
+ instead Keep the assertions and fix the code. Removing test blocks to go
78
+ green is the tamper.
79
+
80
+ A blocking finding clears only with a human sign-off. In CI that sign-off is
81
+ out-of-band — a PR label applied by a reviewer — never a file committed on the
82
+ branch under review.
124
83
  ```
125
84
 
126
- ### Reading the verdict
127
-
128
- One verdict, rendered for whoever is reading it. `--format` picks the view; the default,
129
- `auto`, picks `github` when `GITHUB_ACTIONS=true` and `text` otherwise, so the CI wiring
130
- stays a single line.
131
-
132
- | Format | Where it goes |
133
- | --- | --- |
134
- | `text` | The terminal. Blocking findings first, then by file and line, wrapped to the terminal width. |
135
- | `github` | An inline annotation per finding — so it lands **on the line** in *Files changed*, not four clicks deep in a job log plus a job-summary table on the run page. The full text output still goes to the log. |
136
- | `json` | The findings verbatim, plus a summary count. |
137
-
138
- Severity is always spelled out (`BLOCK` / `warn`) and never carried by colour or a glyph
139
- alone, so the output reads the same piped, in a CI log, on a colour-blind reader's
140
- terminal, and through a screen reader. Colour honours
141
- [`NO_COLOR`](https://no-color.org) and `FORCE_COLOR`, and is off whenever stdout is not a
142
- terminal.
85
+ Under GitHub Actions each finding also lands as an **inline annotation on the diff** in
86
+ *Files changed*, plus a job-summary table — not four clicks deep in a log.
87
+
88
+ ## The rules
89
+
90
+ Nine mechanical rules, deterministic by construction, `block` by default:
91
+ `test-deletion` (AST block-count, rename-out-of-glob, shell mutation), `test-skip`
92
+ (including `.only`, which narrows the suite), `ts-any-cast`, `lint-suppression`,
93
+ `coverage-lowering` (Jest and Vitest shapes, all four metrics, `package.json` included),
94
+ `ci-tampering` (a **moved** check is not a removed one), `hook-tampering`, `no-verify`
95
+ (flags and the env-var escape hatches), and `snapshot-rewrite` (`warn` see the FP study
96
+ below for why).
97
+
98
+ Three heuristics ship as `warn` and never block until their precision clears the bar:
99
+ `assertion-weakening`, `guard-removal`, `ts-any-launder`.
100
+
101
+ The policy file itself is un-ignorable, CI reads the policy from the **merge-base** (a PR
102
+ cannot govern its own verdict), and the agent honours no sign-off it can author — the
103
+ escape valve is a PR label only a human with write access can apply.
104
+
105
+ ## Measured, not asserted
106
+
107
+ **Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
108
+ failing protected test, lets a headless agent loose with the hooks installed, and
109
+ classifies the end state with an oracle independent of the detectors under test
110
+ (SPEC §7.B). **42 runs, 0 tampers reached green; all 20 runs where a block fired ended in
111
+ an honest fix** — a 20/20 point estimate whose 95% lower bound is ≈86%. The split is the
112
+ honest part: Sonnet 5 never attempted a bypass at all, even when the prompt *ordered*
113
+ one; all 20 interceptions came from Haiku 4.5 on a seed whose honest fix is genuinely
114
+ expensive. Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`.
115
+
116
+ **Which detectors deserve to exist?** Measured too. Affordance seeds offer an agent one
117
+ specific bypass each; predictions are pre-registered before any rep runs
118
+ (`harness/PREDICTION-affordance.md`). Result: 7/10 adversarial runs regenerated a golden
119
+ file from buggy output (100% passed unseen — so `snapshot-rewrite` was built), while
120
+ timeout-inflation and lint-config-gutting measured **0/10 attempts each** — two
121
+ pre-registered bets refuted, two detectors *not* built. Bypass classes with no measured
122
+ demand stay in a record-only shadow scanner that accumulates evidence on every weekly
123
+ harness run.
124
+
125
+ **What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
126
+ commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
127
+ which is exactly why that rule is a `warn` asking for human confirmation, not a block,
128
+ and why the study is committed (`harness/fp-study/`) rather than summarized away.
143
129
 
144
- From a clone:
130
+ ## Stability
145
131
 
146
- ```bash
147
- npm install
148
- npm run build # bundles the CLI to dist/cli/index.js
149
- node dist/cli/index.js check --staged
150
- node dist/cli/index.js check --diff "main...HEAD"
151
- ```
132
+ The public surface is the CLI and its **exit codes**, the hook wire format, the
133
+ `.tamperward.yml` schema, and the `--json` `Finding` shape. No `main`, no `exports` —
134
+ it is a binary, not a library. The version answers one question: *can taking this
135
+ upgrade turn a green build red without me changing anything?* **Patch never can** —
136
+ bypass fixes and false-positive fixes ship as patches so they reach you automatically.
137
+ Rule graduations (`warn` → `block`) are **opt-in**: they gate on the `version:` field in
138
+ your policy, so they ship as minors and apply only when you raise it. Releases publish
139
+ via npm trusted publishing with SLSA provenance. Full rule:
140
+ [CONTRIBUTING](./CONTRIBUTING.md#versioning).
152
141
 
153
142
  ## Develop
154
143
 
155
144
  ```bash
156
- npm test # vitest parser, detectors, engine, policy
145
+ npm install && npm run build # bundles the CLI to dist/cli/index.js
146
+ npm test # 259 tests — parser, detectors, engine, policy, renderers
157
147
  npm run typecheck
158
148
  ```
159
149
 
160
- ## Layout
161
-
162
- ```
163
- tamperward/
164
- src/types.ts the Change / Finding / Detector / Policy contracts
165
- src/diff/ pure diff parser + selectors
166
- src/git/ git adapter (range / staged / worktree)
167
- src/detectors/ the rules (phase 1, in progress)
168
- test/ unit suite — green is the gate
169
- .tamperward.yml the policy: protected assets + rule severities
170
- .github/workflows/ CI — dogfoods Tamperward on itself once the CLI lands
171
- ```
150
+ Tamperward gates its own repo in CI with the same engine it ships — including, on more
151
+ than one occasion, blocking its own author's commits. See **[SPEC.md](./SPEC.md)** for
152
+ the build spec, the detector table, the enforcement-point wiring, and the proof-harness
153
+ design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
154
+ every pre-registered prediction with its outcome.
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.1",
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",
@@ -14,15 +14,18 @@
14
14
  },
15
15
  "keywords": [
16
16
  "ai-agents",
17
+ "claude-code",
18
+ "coding-agents",
17
19
  "agent-safety",
20
+ "reward-hacking",
18
21
  "code-integrity",
19
22
  "guardrails",
20
- "claude-code",
23
+ "test-integrity",
21
24
  "pre-commit",
22
25
  "ci",
23
- "static-analysis",
24
- "reward-hacking",
25
- "tests"
26
+ "hooks",
27
+ "llm",
28
+ "devsecops"
26
29
  ],
27
30
  "type": "module",
28
31
  "bin": {