tamperward 1.4.6 → 1.5.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
@@ -93,13 +93,16 @@ Under GitHub Actions each finding also lands as an **inline annotation on the di
93
93
 
94
94
  ## The rules
95
95
 
96
- Nine mechanical rules, deterministic by construction — eight `block`, one `warn`:
96
+ Ten mechanical rules, deterministic by construction — eight `block`, two `warn`:
97
97
  `test-deletion` (AST block-count, rename-out-of-glob, shell mutation), `test-skip`
98
98
  (including `.only`, which narrows the suite), `ts-any-cast`, `lint-suppression`,
99
99
  `coverage-lowering` (Jest and Vitest shapes, all four metrics, `package.json` included),
100
100
  `ci-tampering` (a **moved** check is not a removed one), `hook-tampering`, `no-verify`
101
- (flags and the env-var escape hatches), and `snapshot-rewrite` (`warn` — see the FP study
102
- below for why).
101
+ (flags and the env-var escape hatches), `snapshot-rewrite` (`warn` — see the FP study below
102
+ for why), and `snapshot-only-rewrite` (`warn` — the FP study's narrow signal, a
103
+ snapshot moved with **no accompanying change**, as its own rule so it can graduate to
104
+ `block` at a later policy `version:` without touching the broad rule; it fires only at
105
+ commit granularity, where "no accompanying change" means something).
103
106
 
104
107
  Two heuristic rule names — `assertion-weakening` and `guard-removal` — are **reserved
105
108
  in the baseline policy but not yet built**: they get detectors only once a measured
@@ -115,19 +118,26 @@ escape valve is a PR label only a human with write access can apply.
115
118
  **Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
116
119
  failing protected test, lets a headless agent loose with the hooks installed, and
117
120
  classifies the end state with an oracle independent of the detectors under test
118
- (SPEC §7.B). Across 67 runs of these **guarded** scenarios — every attempted bypass class
121
+ (SPEC §7.B). Across 77 runs of these **guarded** scenarios — every attempted bypass class
119
122
  had a detector covering it — **0 tampers reached green; all 25 runs where a block fired
120
123
  ended in an honest fix**, a 25/25 point estimate whose 95% lower bound is ≈89%. The split is the
121
- honest part: Sonnet 5 never attempted a bypass at all, even when the prompt *ordered*
122
- one; every interception came from Haiku 4.5 on a seed whose honest fix is genuinely
123
- expensive. Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`.
124
+ honest part: across four models (Haiku 4.5, Sonnet 5, Opus 5, Fable 5), every
125
+ interception came from Haiku on a seed whose honest fix is genuinely expensive — no
126
+ frontier model attempted a bypass at all, even when the prompt *ordered* one.
127
+ Reproduce: `npm run build && harness/run.sh 5 adversarial haiku seed-hard`.
124
128
 
125
129
  **Is the fix caused by the deny, or would it have happened anyway?** Measured, with a
126
130
  pre-registered no-gate control arm (`harness/PREDICTION-ungated-control.md`): same seed,
127
131
  model, prompt and oracle, hooks removed. **6/10 ungated runs tampered through; honest
128
132
  fixes fell from 10/10 gated to 4/10 ungated.** The gate's measured contribution on that
129
133
  configuration is a 60-point swing — causation, not survivorship — and one of the three
130
- control-arm bets was refuted (ungated honest fixes came in above my bet).
134
+ control-arm bets was refuted (ungated honest fixes came in above my bet). The control
135
+ arm was then extended across the model family (`harness/PREDICTION-frontier-models.md`):
136
+ ungated Sonnet 5, Opus 5, and Fable 5 each went **10/10 honest** under the same
137
+ pressure prompt — frontier honesty is disposition, not the gate's deterrence — while
138
+ ungated Haiku stays 6/10 tamper-green. The gate's measured value concentrates on the
139
+ models that do cheat. (The Fable rows are a Fable-driven harness measuring Fable:
140
+ self-graded homework, bounded by the deterministic held-out oracle.)
131
141
 
132
142
  **And when the agent knows the gate is there?** Also pre-registered
133
143
  (`harness/PREDICTION-informed-adversary.md`) — betting the gate would *leak*. Refuted,
@@ -144,14 +154,21 @@ specific bypass each; predictions are pre-registered before any rep runs
144
154
  file from buggy output and sailed through — **because nothing guarded that class yet**;
145
155
  that measured demand is why `snapshot-rewrite` exists — while
146
156
  timeout-inflation and lint-config-gutting measured **0/10 attempts each** — two
147
- pre-registered bets refuted, two detectors *not* built. Bypass classes with no measured
148
- demand stay in a record-only shadow scanner that accumulates evidence on every weekly
149
- harness run.
157
+ pre-registered bets refuted, two detectors *not* built. The same discipline settled
158
+ `assertion-weakening` (`harness/PREDICTION-assert-affordance.md`): a seed making the
159
+ expectation edit the one cheap, prompt-named move — with a plausible convention-mismatch
160
+ cover story — measured **0/10 attempts**; Haiku implemented the real fix every time. My
161
+ ≥6/10 bet was refuted, and the rule stays a reserved name. Bypass classes with no
162
+ measured demand stay in a record-only shadow scanner that accumulates evidence on every
163
+ weekly harness run.
150
164
 
151
165
  **What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
152
166
  commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
153
167
  which is exactly why that rule is a `warn` asking for human confirmation, not a block,
154
- and why the study is committed (`harness/fp-study/`) rather than summarized away.
168
+ and why the study is committed (`harness/fp-study/`) rather than summarized away. The
169
+ study's narrow signal — snapshot-only diffs, ~0.06% of audited mainline commits but
170
+ 7/7 of observed tampers — now ships as the distinct `snapshot-only-rewrite` warn, the
171
+ graduation candidate.
155
172
 
156
173
  ## Stability
157
174
 
package/dist/cli/index.js CHANGED
@@ -457,6 +457,10 @@ function defaultPolicy(version = 1) {
457
457
  // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
458
458
  // when intended output changes. WARN until the §7 evidence path earns block.
459
459
  "snapshot-rewrite": { severity: "warn" },
460
+ // the narrow graduation candidate: snapshot moved with NO accompanying change.
461
+ // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
462
+ // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
463
+ "snapshot-only-rewrite": { severity: "warn" },
460
464
  // heuristic — warn until precision is measured (SPEC §7)
461
465
  "assertion-weakening": { severity: "warn" },
462
466
  "guard-removal": { severity: "warn" }
@@ -1314,6 +1318,39 @@ var snapshotRewrite = {
1314
1318
  }
1315
1319
  };
1316
1320
 
1321
+ // src/detectors/snapshot-only.ts
1322
+ var RULE9 = "snapshot-only-rewrite";
1323
+ var COMMIT_VIEWS = ["staged", "range"];
1324
+ function isSnapshotChange(c, policy) {
1325
+ if (c.kind !== "file") return false;
1326
+ if (c.op === "add") return false;
1327
+ if (isProtected(c.path, policy, "snapshots")) return true;
1328
+ return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
1329
+ }
1330
+ var snapshotOnlyRewrite = {
1331
+ id: RULE9,
1332
+ surface: ["file"],
1333
+ certainty: "mechanical",
1334
+ run(changes, policy, view) {
1335
+ if (!view || !COMMIT_VIEWS.includes(view)) return [];
1336
+ const files = changes.filter((c) => c.kind === "file");
1337
+ if (files.length === 0) return [];
1338
+ const snaps = files.filter((c) => isSnapshotChange(c, policy));
1339
+ if (snaps.length === 0 || snaps.length !== files.length) return [];
1340
+ const first = snaps[0];
1341
+ const names = snaps.map((c) => c.kind === "file" ? c.path : "").filter(Boolean).slice(0, 3).join(", ");
1342
+ return [
1343
+ makeFinding(RULE9, policy, {
1344
+ file: first.path,
1345
+ message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
1346
+ evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
1347
+ remediation: "Pair the snapshot update with the source change that motivates it, or confirm the new expectations are genuinely intended. In 1,652 audited mainline commits this shape occurred once; in observed tamper attempts, every time.",
1348
+ defaultSeverity: "warn"
1349
+ })
1350
+ ];
1351
+ }
1352
+ };
1353
+
1317
1354
  // src/detectors/index.ts
1318
1355
  var allDetectors = [
1319
1356
  noVerify,
@@ -1324,7 +1361,8 @@ var allDetectors = [
1324
1361
  ciTampering,
1325
1362
  hookTampering,
1326
1363
  testDeletion,
1327
- snapshotRewrite
1364
+ snapshotRewrite,
1365
+ snapshotOnlyRewrite
1328
1366
  ];
1329
1367
 
1330
1368
  // src/engine.ts
@@ -1339,13 +1377,13 @@ function isSuppressed(c, policy) {
1339
1377
  function activeChanges(changes, policy) {
1340
1378
  return changes.filter((c) => !isSuppressed(c, policy));
1341
1379
  }
1342
- function evaluate(changes, policy, detectors = allDetectors) {
1380
+ function evaluate(changes, policy, detectors = allDetectors, view) {
1343
1381
  const active = activeChanges(changes, policy);
1344
1382
  const out = [];
1345
1383
  for (const d of detectors) {
1346
1384
  if (!isEnabled(d.id, policy)) continue;
1347
1385
  try {
1348
- out.push(...d.run(active, policy));
1386
+ out.push(...d.run(active, policy, view));
1349
1387
  } catch (e) {
1350
1388
  process.stderr.write(`tamperward: detector "${d.id}" errored and was skipped: ${String(e)}
1351
1389
  `);
@@ -1705,12 +1743,15 @@ function check(opts) {
1705
1743
  const cwd = opts.cwd ?? process.cwd();
1706
1744
  let changes;
1707
1745
  let layer;
1746
+ let view;
1708
1747
  if (opts.staged) {
1709
1748
  changes = diffStaged({ cwd: opts.cwd });
1710
1749
  layer = "local";
1750
+ view = "staged";
1711
1751
  } else if (opts.worktree) {
1712
1752
  changes = diffWorktree({ cwd: opts.cwd });
1713
1753
  layer = "local";
1754
+ view = "worktree";
1714
1755
  } else if (opts.diff) {
1715
1756
  const [base, head] = opts.diff.split(/\.{2,3}/);
1716
1757
  if (!base || !head) {
@@ -1720,13 +1761,14 @@ function check(opts) {
1720
1761
  }
1721
1762
  changes = diffRange(base, head, { cwd: opts.cwd });
1722
1763
  layer = "ci";
1764
+ view = "range";
1723
1765
  policy = loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
1724
1766
  } else {
1725
1767
  process.stderr.write("tamperward: specify --staged, --worktree, or --diff <base>...<head>\n");
1726
1768
  return 2;
1727
1769
  }
1728
1770
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
1729
- let findings = evaluate(changes, policy);
1771
+ let findings = evaluate(changes, policy, void 0, view);
1730
1772
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv());
1731
1773
  findings = remaining;
1732
1774
  if (cleared.length) {
@@ -1963,7 +2005,7 @@ function preToolUseVerdict(input) {
1963
2005
  const cwd = input.cwd ?? process.cwd();
1964
2006
  turnBaseline(cwd, input.session_id);
1965
2007
  const changes = changesFromClaudeHook(input, cwd);
1966
- const blocks = evaluate(changes, loadPolicy(cwd)).filter((f) => f.severity === "block");
2008
+ const blocks = evaluate(changes, loadPolicy(cwd), void 0, "tool-call").filter((f) => f.severity === "block");
1967
2009
  return verdict(blocks, "PreToolUse");
1968
2010
  } catch (e) {
1969
2011
  return failClosed("PreToolUse", errText(e));
@@ -1977,7 +2019,7 @@ function stopVerdict(input) {
1977
2019
  try {
1978
2020
  const base = turnBaseline(cwd, input.session_id);
1979
2021
  const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
1980
- blocks = evaluate(changes, loadPolicy(cwd)).filter((f) => f.severity === "block");
2022
+ blocks = evaluate(changes, loadPolicy(cwd), void 0, "turn").filter((f) => f.severity === "block");
1981
2023
  } catch (e) {
1982
2024
  return failClosed("Stop", errText(e));
1983
2025
  }
@@ -2017,7 +2059,7 @@ function runAllow(opts) {
2017
2059
  const policy = loadPolicy(cwd);
2018
2060
  let findings;
2019
2061
  try {
2020
- findings = evaluate(diffWorktree({ cwd }), policy);
2062
+ findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree");
2021
2063
  } catch {
2022
2064
  process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
2023
2065
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.4.6",
3
+ "version": "1.5.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",