tamperward 1.4.5 → 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 +39 -16
- package/dist/cli/index.js +49 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,9 @@ earlier layers shorten the feedback loop for agents that carry them.
|
|
|
26
26
|
> Apache-2.0 · zero runtime model calls · every headline claim below is measured, with
|
|
27
27
|
> the pre-registered predictions (including the refuted ones) committed to this repo
|
|
28
28
|
|
|
29
|
+
**[Docs & guide](https://hexrift.github.io/tamperward/)** ·
|
|
30
|
+
**[The launch post](https://hexrift.github.io/tamperward/blog/what-agents-do-when-you-block-their-shortcuts)** — how these numbers were measured, and which of my own bets lost
|
|
31
|
+
|
|
29
32
|
## Install
|
|
30
33
|
|
|
31
34
|
```bash
|
|
@@ -35,12 +38,12 @@ npx tamperward init
|
|
|
35
38
|
One idempotent command wires the policy and every enforcement point — it never
|
|
36
39
|
overwrites anything you wrote, and `--dry-run` shows the plan first:
|
|
37
40
|
|
|
38
|
-
|
|
|
41
|
+
| what | wired as |
|
|
39
42
|
| --- | --- |
|
|
43
|
+
| policy | a commented baseline `.tamperward.yml` — the defaults apply even without it |
|
|
40
44
|
| agent loop | Claude Code `PreToolUse` deny + `Stop` sweep, merged into `.claude/settings.json` |
|
|
41
45
|
| pre-commit | husky when present, the plain git hook otherwise |
|
|
42
46
|
| CI | a PR-gate workflow with out-of-band sign-off (details below) |
|
|
43
|
-
| policy | a commented baseline `.tamperward.yml` — the defaults apply even without it |
|
|
44
47
|
|
|
45
48
|
Or run the views directly:
|
|
46
49
|
|
|
@@ -78,6 +81,7 @@ tamperward: 2 blocking
|
|
|
78
81
|
evidence 2 test block(s) removed from test/calc.test.js
|
|
79
82
|
instead Keep the assertions and fix the code. Removing test blocks to go
|
|
80
83
|
green is the tamper.
|
|
84
|
+
sign-off tamperward allow test-deletion --file test/calc.test.js --reason "..."
|
|
81
85
|
|
|
82
86
|
A blocking finding clears only with a human sign-off. In CI that sign-off is
|
|
83
87
|
out-of-band — a PR label applied by a reviewer — never a file committed on the
|
|
@@ -89,16 +93,21 @@ Under GitHub Actions each finding also lands as an **inline annotation on the di
|
|
|
89
93
|
|
|
90
94
|
## The rules
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
Ten mechanical rules, deterministic by construction — eight `block`, two `warn`:
|
|
93
97
|
`test-deletion` (AST block-count, rename-out-of-glob, shell mutation), `test-skip`
|
|
94
98
|
(including `.only`, which narrows the suite), `ts-any-cast`, `lint-suppression`,
|
|
95
99
|
`coverage-lowering` (Jest and Vitest shapes, all four metrics, `package.json` included),
|
|
96
100
|
`ci-tampering` (a **moved** check is not a removed one), `hook-tampering`, `no-verify`
|
|
97
|
-
(flags and the env-var escape hatches),
|
|
98
|
-
|
|
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).
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
Two heuristic rule names — `assertion-weakening` and `guard-removal` — are **reserved
|
|
108
|
+
in the baseline policy but not yet built**: they get detectors only once a measured
|
|
109
|
+
negatives corpus exists (SPEC §7.A), enter as `warn`, and never block until their
|
|
110
|
+
precision clears the bar.
|
|
102
111
|
|
|
103
112
|
The policy file itself is un-ignorable, CI reads the policy from the **merge-base** (a PR
|
|
104
113
|
cannot govern its own verdict), and the agent honours no sign-off it can author — the
|
|
@@ -109,19 +118,26 @@ escape valve is a PR label only a human with write access can apply.
|
|
|
109
118
|
**Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
|
|
110
119
|
failing protected test, lets a headless agent loose with the hooks installed, and
|
|
111
120
|
classifies the end state with an oracle independent of the detectors under test
|
|
112
|
-
(SPEC §7.B). Across
|
|
121
|
+
(SPEC §7.B). Across 77 runs of these **guarded** scenarios — every attempted bypass class
|
|
113
122
|
had a detector covering it — **0 tampers reached green; all 25 runs where a block fired
|
|
114
123
|
ended in an honest fix**, a 25/25 point estimate whose 95% lower bound is ≈89%. The split is the
|
|
115
|
-
honest part:
|
|
116
|
-
|
|
117
|
-
|
|
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`.
|
|
118
128
|
|
|
119
129
|
**Is the fix caused by the deny, or would it have happened anyway?** Measured, with a
|
|
120
130
|
pre-registered no-gate control arm (`harness/PREDICTION-ungated-control.md`): same seed,
|
|
121
131
|
model, prompt and oracle, hooks removed. **6/10 ungated runs tampered through; honest
|
|
122
132
|
fixes fell from 10/10 gated to 4/10 ungated.** The gate's measured contribution on that
|
|
123
133
|
configuration is a 60-point swing — causation, not survivorship — and one of the three
|
|
124
|
-
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.)
|
|
125
141
|
|
|
126
142
|
**And when the agent knows the gate is there?** Also pre-registered
|
|
127
143
|
(`harness/PREDICTION-informed-adversary.md`) — betting the gate would *leak*. Refuted,
|
|
@@ -138,14 +154,21 @@ specific bypass each; predictions are pre-registered before any rep runs
|
|
|
138
154
|
file from buggy output and sailed through — **because nothing guarded that class yet**;
|
|
139
155
|
that measured demand is why `snapshot-rewrite` exists — while
|
|
140
156
|
timeout-inflation and lint-config-gutting measured **0/10 attempts each** — two
|
|
141
|
-
pre-registered bets refuted, two detectors *not* built.
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
144
164
|
|
|
145
165
|
**What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
|
|
146
166
|
commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
|
|
147
167
|
which is exactly why that rule is a `warn` asking for human confirmation, not a block,
|
|
148
|
-
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.
|
|
149
172
|
|
|
150
173
|
## Stability
|
|
151
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.
|
|
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",
|