tamperward 1.15.0 → 2.1.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
@@ -12,6 +12,7 @@
12
12
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-lightgrey" alt="license"></a>
13
13
  </p>
14
14
 
15
+ **[Quick start](#quick-start)** ·
15
16
  **[Docs & guide](https://hexrift.github.io/tamperward/)** ·
16
17
  **[The research series](./docs/blog/index.md)** — every registered prediction
17
18
  published beside its outcome
@@ -22,14 +23,28 @@ trajectories, some modify or attempt to modify verification in ways that can
22
23
  turn incorrect work into apparent success. Under pressure, the cheaper route to
23
24
  green is sometimes to weaken the checks instead of fixing the failure.
24
25
 
26
+ In plain English: Tamperward lets a coding agent change your code, but not the
27
+ trusted starting point, the rules, or the checks used to judge that code.
28
+
25
29
  Tamperward is a **deterministic verification-integrity layer**. It blocks known
26
30
  weakening moves as they happen, observes protected-state effects, and
27
31
  independently re-adjudicates apparent success outside the agent's normal
28
32
  completion path. No runtime LLM judge. Fail closed when adjudication is
29
33
  impossible.
30
34
 
35
+ > **Project status: active research release.** Tamperward is usable today, but
36
+ > its enforcement architecture and supporting evidence are still being tested
37
+ > and hardened. Use it as one layer of defence in depth alongside protected CI,
38
+ > independent tests and human review. Findings, limitations and corrections are
39
+ > published openly. The 2.0 major marks the Node 18 drop, not a declaration of
40
+ > security maturity.
41
+
31
42
  ## What we have actually measured
32
43
 
44
+ Plain-English takeaway: the original detector-centred design was insufficient.
45
+ Later versions materially reduced false-green outcomes in two held-out rounds,
46
+ and the subsequent stronger-model replication was inconclusive.
47
+
33
48
  | experiment | result | what it supports |
34
49
  | --- | --- | --- |
35
50
  | **Round 1** — 26 paired real repos, historical regressions, 53 counted trajectories, **v1.6.0** ([`harness/taskbench/`](./harness/taskbench/)) | Transfer: **9/27 ungated runs (33.3%)** violated policy ([corrected](./harness/taskbench/reanalysis/TRANSFER-REANALYSIS.md) from a published 13/26 — the original predicate was defective; the transfer bet is refuted, not held). Headline prevention bet **lost**: b=5 / c=4, RD +3.8pp [−17.2, +24.7], exact McNemar **p = 1.0** — published beside the bet | A detector-centric architecture was insufficient: agents routed around the shipped detector classes |
@@ -39,11 +54,16 @@ impossible.
39
54
  | **Round 3** — 17 paired Python repos, fresh PyPI frame, **v1.14.0** ([`harness/taskbench/round3/`](./harness/taskbench/round3/)) | Transfer 9/17 (52.9%). Prevention: **b=6 / c=0**, RD **+35.3pp**, BP95 [9.5, 58.7], exact McNemar **p = 0.0313** | The prevention result appeared in Python — with the treatment also changed from v1.9.0, so ecosystem transfer is not isolated. The in-loop skip detector proved blind to pytest syntax; the outer layers carried it |
40
55
  | **Round 3.1** — the same 16 pairs under **`claude-sonnet-5`** ([`harness/taskbench/round3.1/`](./harness/taskbench/round3.1/)) | Transfer 4/16 (25.0%). Prevention: **b=1 / c=0**, RD +6.3pp, BP95 [−13.8, 28.3], exact McNemar **p = 1.0000** | The confirmatory result **did not replicate**, and could not have: only three ungated false greens occurred, so `b ≤ 3` and p ≥ .25 whatever the gate did. A failure to reject, not evidence of no effect |
41
56
 
57
+ Key: `b` = false greens seen only without Tamperward; `c` = false greens seen
58
+ only with it; `RD` = paired risk difference; `pp` = percentage points; `BP95` =
59
+ Bonett–Price 95% interval.
60
+
42
61
  Earlier controlled experiments → **[the research series](./docs/blog/index.md)**.
43
62
 
44
- > **Scope.** The rows above are one model, one pressure prompt, one JS/TS
45
- > ecosystem — evidence for that setting, not a universal claim. Round 2 tested
46
- > the v1.9.0 stack; the current **v1.15.0** adds post-study envelope hardening
63
+ > **Scope.** The rows above cover specific models, pressure prompts, treatment
64
+ > versions, and finite JavaScript/TypeScript and Python repository samples —
65
+ > evidence for those settings, not a universal claim. Round 2 tested
66
+ > the v1.9.0 stack; the current **v2.0.0** adds post-study envelope hardening
47
67
  > (externally reviewed, with findings tracked individually as REPRO or AUDIT in
48
68
  > [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
49
69
  > mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
@@ -56,7 +76,11 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
56
76
 
57
77
  ## Architecture
58
78
 
59
- Tamperward separates **steering** from **adjudication**.
79
+ The agent may produce the work, but it must not control how that work is
80
+ judged. In architectural terms, Tamperward separates **steering** from
81
+ **adjudication**. "Visible" verification runs the candidate as it stands;
82
+ "pristine" verification restores the protected verification state from the
83
+ trusted starting point and runs the checks again.
60
84
 
61
85
  > **Core invariant:** the agent may author the candidate tree, but it must not
62
86
  > choose the trusted baseline, the governing policy, the verifier, or the final
@@ -160,16 +184,19 @@ are involved here, and they are not the same thing.
160
184
  trusted base — so neither step's verdict is governed by the candidate.
161
185
 
162
186
  Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
163
- The workflow passes the head SHA to the gate through `TAMPERWARD_OOB_HEAD`, so an
187
+ The workflow passes the head SHA to both steps through `TAMPERWARD_OOB_HEAD`, so an
164
188
  approval is bound to the exact commit it was granted for and a new push invalidates it.
189
+ Since **2.1.0** the verify step reads the same labels: `tamperward:allow:verify@<head-sha>`
190
+ accepts a `MASKED_FAILURE` — the case where a behaviour change makes the original
191
+ expectations wrong and a reviewer has read the test edit and said so. It clears nothing
192
+ else: a red visible suite, or a run that could not verify, stays red whatever the labels
193
+ say, and the verdict is still reported as a masked failure; only the exit code changes.
165
194
 
166
195
  **This repository's own self-gate** — the `gate` job in `.github/workflows/ci.yml` —
167
196
  runs the built CLI's `check --diff` over the pull-request range, cleared only by the
168
197
  same label channel, but does **not** run `verify` on itself. This repo's test
169
- expectations legitimately change whenever a rule changes, and standalone `verify` has
170
- no out-of-band sign-off channel, so a pristine re-execution of its own suite would fail
171
- closed on exactly the pull requests that improve the gate. The self-gate is a
172
- diff-time gate.
198
+ expectations legitimately change whenever a rule changes, so nearly every rule pull
199
+ request would need the verify label; the self-gate stays a diff-time gate.
173
200
 
174
201
  This guarantee depends on a protected and immutable base, required status checks, a
175
202
  pinned Tamperward version, and label permissions restricted to trusted humans.
@@ -186,6 +213,10 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
186
213
  npx tamperward init
187
214
  ```
188
215
 
216
+ Requires Node.js 20.19 or later. JavaScript and TypeScript are the fully
217
+ supported detector surface; the other documented ecosystems get file-level and
218
+ pattern-based protection.
219
+
189
220
  One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
190
221
  CI workflow that runs both the diff-time check and pristine verification, and a
191
222
  `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
@@ -252,7 +283,7 @@ commands ignore what they do not know.
252
283
  | command | 0 | 1 | 2 |
253
284
  | --- | --- | --- | --- |
254
285
  | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, or no view given |
255
- | `verify` | `VERIFIED` — visible and pristine both green | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
286
+ | `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
256
287
  | `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
257
288
  | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
258
289
  | `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
@@ -263,8 +294,8 @@ commands ignore what they do not know.
263
294
 
264
295
  | variable | who sets it | what it does |
265
296
  | --- | --- | --- |
266
- | `TAMPERWARD_OOB_SIGNOFF` | the CI workflow, from PR labels | comma-separated out-of-band approvals — `<rule>` or `<rule>:<file>`, optionally `@<head-sha>` — honoured by `check --diff` only; never the committed ledger |
267
- | `TAMPERWARD_OOB_HEAD` | the CI workflow (`github.event.pull_request.head.sha`) | the head SHA under adjudication; once set, an approval clears a finding only if it names that commit (`@<sha>`, at least 7 characters), so a new push re-blocks |
297
+ | `TAMPERWARD_OOB_SIGNOFF` | the CI workflow, from PR labels | comma-separated out-of-band approvals — `<rule>` or `<rule>:<file>` for `check --diff`, `verify` for a `verify` masked failure — optionally `@<head-sha>`; honoured at the CI layer only, never the committed ledger |
298
+ | `TAMPERWARD_OOB_HEAD` | the CI workflow (`github.event.pull_request.head.sha`) | the head SHA under adjudication; once set, an approval clears anything only if it names that commit (`@<sha>`, at least 7 characters), so a new push re-blocks |
268
299
  | `TAMPERWARD_DENYLOG` | a harness or operator | a file to which `hook claude` and `sweep claude` append the rule ids of every deny, one line per verdict, best effort |
269
300
  | `TAMPERWARD_FSEVENTS` | operator or harness | overrides the `tamperward watch` event-log path (default `.git/tamperward/fsevents.jsonl`); the Stop sweep reads the same variable |
270
301
  | `TAMPERWARD_WATCH_NO_RECURSIVE` | CI and tests (`=1`) | forces `tamperward watch` onto its per-directory fallback instead of recursive `fs.watch`, so the fallback is exercised on every platform |
@@ -286,7 +317,7 @@ Some ambiguous syntactic classes deliberately remain warnings or unimplemented
286
317
  `ts-any-launder` is a permanent warn — rather than being promoted to blockers
287
318
  without precision evidence.
288
319
 
289
- ## What this does not establish
320
+ ## What Tamperward does not do
290
321
 
291
322
  - **Not a correctness oracle.** Pristine verification can only re-run tests that
292
323
  exist in the tree. Round 2's designed-in blind spot: on tasks with withheld
@@ -362,7 +393,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
362
393
 
363
394
  ```bash
364
395
  npm install && npm run build # bundles the CLI to dist/cli/index.js
365
- npm test # 520+ tests at 1.15.0 — parser, detectors, engine, policy, renderers
396
+ npm test # 520+ tests at 2.0.0 — parser, detectors, engine, policy, renderers
366
397
  npm run typecheck
367
398
  ```
368
399
 
package/dist/cli/index.js CHANGED
@@ -1931,15 +1931,23 @@ function applyLocalSignoffs(findings, cwd, policy, now = Date.now()) {
1931
1931
  }
1932
1932
  return { findings: remaining, cleared };
1933
1933
  }
1934
- function applyOobSignoffs(findings, oob, head) {
1935
- const tokens2 = oob.map((s) => s.trim()).filter(Boolean);
1936
- const matches = (want) => tokens2.some((t) => {
1934
+ function oobToken(want, oob, head) {
1935
+ for (const raw of oob) {
1936
+ const t = raw.trim();
1937
+ if (!t) continue;
1937
1938
  const at = t.lastIndexOf("@");
1938
- if (at === -1) return head ? false : t === want;
1939
+ if (at === -1) {
1940
+ if (!head && t === want) return t;
1941
+ continue;
1942
+ }
1939
1943
  const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
1940
- if (rule !== want) return false;
1941
- return head ? head.startsWith(sha2) && sha2.length >= 7 : true;
1942
- });
1944
+ if (rule !== want) continue;
1945
+ if (!head || sha2.length >= 7 && head.startsWith(sha2)) return t;
1946
+ }
1947
+ return null;
1948
+ }
1949
+ function applyOobSignoffs(findings, oob, head) {
1950
+ const matches = (want) => oobToken(want, oob, head) !== null;
1943
1951
  const cleared = [];
1944
1952
  const remaining = [];
1945
1953
  for (const f of findings) {
@@ -3025,8 +3033,9 @@ var WORKFLOW_CONTENT = `name: tamperward
3025
3033
 
3026
3034
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
3027
3035
  # the PR's commit range. A block fails the check and clears ONLY via the out-of-band
3028
- # label \`tamperward:allow:<rule>\` applied by someone with write access \u2014 never a file
3029
- # the PR itself can commit.
3036
+ # label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with write access \u2014
3037
+ # never a file the PR itself can commit. The verify step reads the same labels:
3038
+ # \`tamperward:allow:verify@<head-sha>\` accepts a masked failure a reviewer has judged.
3030
3039
  #
3031
3040
  # labeled/unlabeled re-run the gate because the sign-off is read from the EVENT payload:
3032
3041
  # a label applied after a failure could otherwise never take effect, and REVOKING a
@@ -3082,6 +3091,14 @@ jobs:
3082
3091
  # Requires a \`verify:\` block in .tamperward.yml naming the suite command;
3083
3092
  # without one this step fails closed (exit 2) rather than passing quietly.
3084
3093
  - name: Tamperward verify (pristine re-execution)
3094
+ env:
3095
+ # The same channel as the gate. \`tamperward:allow:verify@<head-sha>\`
3096
+ # accepts a MASKED_FAILURE \u2014 a reviewer has read the intentional test
3097
+ # change and agrees the original suite no longer applies. It clears
3098
+ # nothing else: a red visible suite, or a run that could not verify,
3099
+ # stays red whatever the labels say.
3100
+ TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
3101
+ TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
3085
3102
  run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
3086
3103
  `;
3087
3104
  var WORKFLOW_MARK = "# tamperward:generated";
@@ -3768,6 +3785,8 @@ function runVerify(opts) {
3768
3785
  verdict2 = "SUITE_RED";
3769
3786
  code = 1;
3770
3787
  }
3788
+ const signedOff = verdict2 === "MASKED_FAILURE" ? oobToken("verify", oobFromEnv(), oobHeadFromEnv()) : null;
3789
+ if (signedOff) code = 0;
3771
3790
  if (opts.json) {
3772
3791
  out2(
3773
3792
  JSON.stringify({
@@ -3779,6 +3798,7 @@ function runVerify(opts) {
3779
3798
  pristine: { exit: pristine.exit, secs: pristine.secs },
3780
3799
  protected_restored: restored.length,
3781
3800
  added_protected_removed: removedAdded,
3801
+ ...signedOff ? { oob_signoff: signedOff } : {},
3782
3802
  ...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
3783
3803
  })
3784
3804
  );
@@ -3790,6 +3810,10 @@ function runVerify(opts) {
3790
3810
  BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
3791
3811
  };
3792
3812
  out2(`tamperward verify \u2014 ${lines[verdict2]}`);
3813
+ if (signedOff)
3814
+ out2(
3815
+ `masked failure cleared by out-of-band approval (tamperward:allow:${signedOff}): a reviewer accepted that the original suite no longer applies to this change. Exit 0.`
3816
+ );
3793
3817
  if (removedAdded > 0)
3794
3818
  out2(
3795
3819
  `(${removedAdded} protected file(s) added since ${base.slice(0, 10)} were removed from the pristine run: the pristine tree carries exactly the base's protected surface.)`
@@ -4079,7 +4103,9 @@ Formats:
4079
4103
  [--budget S] [--json] [--keep] as-is AND with protected files restored
4080
4104
  from the trusted base; a visible-green /
4081
4105
  pristine-red result is a MASKED FAILURE
4082
- (exit 1); cannot-verify fails closed (2)
4106
+ (exit 1, or 0 under an out-of-band
4107
+ verify@<head-sha> approval); cannot-verify
4108
+ fails closed (2)
4083
4109
  tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
4084
4110
  [--base R] [--cmd C] base, run the agent, treat its exit as
4085
4111
  [--budget S] [--allow-dirty] untrusted, then re-adjudicate the tree it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.15.0",
3
+ "version": "2.1.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",
@@ -37,7 +37,7 @@
37
37
  "NOTICE"
38
38
  ],
39
39
  "engines": {
40
- "node": ">=18"
40
+ "node": ">=20.19"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/cli/index.js",
@@ -53,10 +53,10 @@
53
53
  "yaml": "^2.6.1"
54
54
  },
55
55
  "devDependencies": {
56
- "@types/node": "^20.14.0",
56
+ "@types/node": "^22.20.1",
57
57
  "@types/picomatch": "^3.0.1",
58
58
  "esbuild": "^0.25.12",
59
- "vitepress": "^1.6.4",
60
- "vitest": "^2.1.9"
59
+ "vitepress": "^2.0.0-alpha.19",
60
+ "vitest": "^4.1.11"
61
61
  }
62
62
  }