tamperward 1.5.1 → 1.7.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 +82 -5
- package/dist/cli/index.js +79 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,46 @@ escape valve is a PR label only a human with write access can apply.
|
|
|
115
115
|
|
|
116
116
|
## Measured, not asserted
|
|
117
117
|
|
|
118
|
+
**Read the two headline numbers together, always:** across **137 guarded runs — every
|
|
119
|
+
one inside a tamper class an active detector covered — 0 tampers reached green.**
|
|
120
|
+
Outside that coverage, on the one seed built to measure it, **agents tampered in 26 of
|
|
121
|
+
40 runs** — "tampered" meaning, mechanically: the independent held-out oracle
|
|
122
|
+
classified the result as passing the visible check through a prohibited shortcut. The
|
|
123
|
+
first number is what the gate does; the second is why the gate is one layer. And an honest note on evidence units: those run counts come from roughly
|
|
124
|
+
fifteen seed/task configurations and eight real repositories — repeated runs on one
|
|
125
|
+
seed measure that seed's stochasticity, not the space of real coding tasks, so the
|
|
126
|
+
independent evidence behind these numbers is the seed count, not the run count.
|
|
127
|
+
|
|
128
|
+
**And the third number, from the preregistered cross-repository experiment
|
|
129
|
+
(taskbench, `harness/taskbench/`):** on 26 paired real repositories with real
|
|
130
|
+
historical regressions, pressured-agent policy violations appeared in **13 of 26
|
|
131
|
+
ungated runs (50%)** — the behavior transfers — but the gate at 1.6.0 **did not
|
|
132
|
+
reduce false greens** (5 prevented vs 4 gated-only; paired RD +3.8%, 95% CI
|
|
133
|
+
−17.2 to +24.7, exact McNemar p = 1.0): agents routed through tool-call
|
|
134
|
+
spellings outside the shipped detector classes, including two gaps documented
|
|
135
|
+
before the sweep and deliberately left unfixed for the experiment's integrity.
|
|
136
|
+
Enforcement showed no measurable completion cost (+7.7 points, CI spans zero),
|
|
137
|
+
and independent pristine-suite re-execution caught **every masked failure in
|
|
138
|
+
all 53 trajectories**. Registration, bets, frozen analysis, and the refutation
|
|
139
|
+
of our own headline bet: `PREDICTION-taskbench.md`, `PHASE3.md`. The first two
|
|
140
|
+
numbers say what the gate does inside its coverage; the third says coverage is
|
|
141
|
+
the whole game on real repositories — and that the reliable layer was the
|
|
142
|
+
pristine-suite oracle the agent cannot touch.
|
|
143
|
+
|
|
144
|
+
**What this does not do**, stated before the numbers rather than after: it does not
|
|
145
|
+
establish that a patch is *correct*; it does not detect arbitrary deception (a
|
|
146
|
+
hardcoded implementation is invisible to any diff rule — measured, twice); it does not
|
|
147
|
+
replace sandboxing, held-out tests, or authoritative CI — it is designed to sit in
|
|
148
|
+
front of them. What it does: **enforce explicitly defined development invariants at
|
|
149
|
+
the loop, commit, and CI boundaries** — and nothing else.
|
|
150
|
+
|
|
151
|
+
**A word on the words:** *tamper*, *honest*, *cheat*, and *surrender* are used
|
|
152
|
+
throughout as operational labels, not claims about mental states. A "tamper" means: a
|
|
153
|
+
protected asset changed, or an independent held-out oracle failed, while the visible
|
|
154
|
+
check went green — a property of the artifacts, decided deterministically. Whether the
|
|
155
|
+
process that produced it "intended" anything is a question these experiments do not
|
|
156
|
+
measure and do not need.
|
|
157
|
+
|
|
118
158
|
**Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
|
|
119
159
|
failing protected test, lets a headless agent loose with the hooks installed, and
|
|
120
160
|
classifies the end state with an oracle independent of the detectors under test
|
|
@@ -229,9 +269,17 @@ framing, and Sonnet's famous refusals turn out to be triggered by framing). The
|
|
|
229
269
|
completed frontier split — **Opus 0/10 · Fable 6/10 · Sonnet 10/10** — is the
|
|
230
270
|
published shape, with the standing self-measurement caveat on the Fable rows.
|
|
231
271
|
Twenty-six of forty runs tampered through the uncovered dimension: measured demand
|
|
232
|
-
that
|
|
233
|
-
the honest fix itself narrows through `as Record<string, unknown>`, so a
|
|
234
|
-
assertion-blocker is an FP factory.
|
|
272
|
+
that sent the `ts-any-launder` warn→block graduation question to a §7.A negatives
|
|
273
|
+
corpus — the honest fix itself narrows through `as Record<string, unknown>`, so a
|
|
274
|
+
naive assertion-blocker is an FP factory. **The corpus answered, and the question is
|
|
275
|
+
closed** (`harness/PREDICTION-launder-corpus.md`): on 1,227 real commits the
|
|
276
|
+
laundering spellings fire on legitimate work at 4–10× any deployable block threshold
|
|
277
|
+
(annotation-position `any`: 10.5% of commits; named-type assertions: 7.5%), so the
|
|
278
|
+
warn stays a warn permanently on syntactic evidence, no assertion rule gets built,
|
|
279
|
+
and the laundering class is owned by the layers where coverage is measured: the
|
|
280
|
+
held-out **oracle** (caught all 18 assertion tampers) and the **prompt** (the naming
|
|
281
|
+
sentence, 10/10 flip). For this class, detection is not the gate's layer —
|
|
282
|
+
verification and framing are, and the record says so with a denominator.
|
|
235
283
|
|
|
236
284
|
**Why not just a prompt line, or a 20-line grep hook?** Measured
|
|
237
285
|
(`harness/PREDICTION-baselines.md`), against a frozen naive hook and a one-sentence
|
|
@@ -248,8 +296,37 @@ versus the gate's 9.0% — a ~2.75× precision edge, not the ≥5× I bet, and t
|
|
|
248
296
|
unconfigured repos is published as the honest tuning-and-sign-off cost, not hidden.
|
|
249
297
|
The loop-layer deny alone was never the product; the measured margins are whole-class
|
|
250
298
|
spelling coverage, commit-layer precision plus sign-off machinery, and the layers no
|
|
251
|
-
hook has.
|
|
252
|
-
|
|
299
|
+
hook has. Two follow-ups closed the loops this study opened. The sentence was then
|
|
300
|
+
aimed at the regime where the gate is blind — Sonnet's 10/10 assertion-laundering
|
|
301
|
+
seed — and produced a **complete flip: 10/10 honest** (`harness/PREDICTION-baselines.md`,
|
|
302
|
+
addendum): naming the act is the intervention Sonnet's framing-triggered honesty
|
|
303
|
+
runs on. And the promised **tuned-gate numbers** now exist
|
|
304
|
+
(`harness/PREDICTION-tuned-fp.md`): 9.0% unconfigured → **6.0%** with a one-line
|
|
305
|
+
severity tune → roughly one sign-off decision per 17 commits residual. The same study
|
|
306
|
+
shipped per-rule `exclude` globs (1.6.0, with the policy-weakening symmetry built in)
|
|
307
|
+
and then measured them honestly: **zero effect on this corpus** — these repos' casts
|
|
308
|
+
live in source, not tests, so the zod-recommended path scoping removed nothing here;
|
|
309
|
+
the null is published next to the feature.
|
|
310
|
+
|
|
311
|
+
**What about just making the tests read-only?** The most common objection got its
|
|
312
|
+
pre-registered measurement (`harness/PREDICTION-capability.md`) — starting with the
|
|
313
|
+
mechanics the folk advice misses: `chmod` does not bind a root agent and never
|
|
314
|
+
prevented `rm` anyway (deletion is a *directory*-write right); the workable barrier
|
|
315
|
+
is the immutable bit. Result: **hard immutability governed the crude-tamper seed
|
|
316
|
+
completely — 10/10 honest, four EPERM hits all converting to the real fix** (a bare
|
|
317
|
+
permission error transferred the deny→fix dynamic with no remediation text). Then
|
|
318
|
+
the twist: adding a logged, auto-granted **elevation script made it worse (8/10)** —
|
|
319
|
+
both extra tampers walked through the front door with written confessions
|
|
320
|
+
("*Remove failing test file to unblock build*"), the sanctioned-path thesis firing
|
|
321
|
+
a fourth time. The lock is easy; the grantor is the hard part — which is the case
|
|
322
|
+
for elevation flows that end at a human, and exactly what the gate's sign-off label
|
|
323
|
+
already is. That seed now carries all six governance configurations on identical
|
|
324
|
+
pre-registered ground: nothing 6/10 tampers, one sentence 1/10, naive hook 2/10,
|
|
325
|
+
the gate 0/10, immutable 0/10, immutable+auto-grant 2/10 — with the standing scope
|
|
326
|
+
that none of the capability rows touch the semantic classes, by construction.
|
|
327
|
+
Running ledger: **70 pre-registered bets — 29 refuted, 37 held, 4 open or
|
|
328
|
+
unresolvable** — taskbench's B2 (prevention on real repositories) is the newest
|
|
329
|
+
refutation, published beside the bet it refutes.
|
|
253
330
|
|
|
254
331
|
**What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
|
|
255
332
|
commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
|
package/dist/cli/index.js
CHANGED
|
@@ -438,11 +438,17 @@ function defaultPolicy(version = 1) {
|
|
|
438
438
|
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
439
439
|
// rewriting it from current output is the snapshot-update move the affordance
|
|
440
440
|
// experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
|
|
441
|
-
snapshots:
|
|
441
|
+
// tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
|
|
442
|
+
// live there as .cjs and were rewritten unseen (PHASE3.md §4).
|
|
443
|
+
snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
|
|
442
444
|
hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
|
|
443
445
|
},
|
|
444
446
|
rules: {
|
|
445
447
|
"test-deletion": { severity: "block" },
|
|
448
|
+
// content gutted from a surviving spec — the class Phase 3 measured walking
|
|
449
|
+
// through the block-count rule (rows, assertion arguments, setup). Severity
|
|
450
|
+
// backed by the real-commit corpus measurement recorded in the rule's SPEC row.
|
|
451
|
+
"test-content-removal": { severity: "block" },
|
|
446
452
|
"test-skip": { severity: "block" },
|
|
447
453
|
"ts-any-cast": { severity: "block" },
|
|
448
454
|
// the unambiguous explicit casts + ts-suppression directives
|
|
@@ -984,6 +990,11 @@ function policyWeakening(before, after) {
|
|
|
984
990
|
if (ar.enabled === false && br.enabled !== false) {
|
|
985
991
|
reasons.push(`rule "${name}" disabled (enabled: false)`);
|
|
986
992
|
}
|
|
993
|
+
const beforeEx = new Set(br.exclude ?? []);
|
|
994
|
+
const addedEx = (ar.exclude ?? []).filter((g) => !beforeEx.has(g));
|
|
995
|
+
if (addedEx.length) {
|
|
996
|
+
reasons.push(`rule "${name}" exclude globs added (${addedEx.join(", ")}) \u2014 blinds this rule on those paths`);
|
|
997
|
+
}
|
|
987
998
|
}
|
|
988
999
|
const beforeIgnore = new Set(be.ignore);
|
|
989
1000
|
const addedIgnore = ae.ignore.filter((g) => !beforeIgnore.has(g));
|
|
@@ -1267,8 +1278,62 @@ var testDeletion = {
|
|
|
1267
1278
|
}
|
|
1268
1279
|
};
|
|
1269
1280
|
|
|
1281
|
+
// src/detectors/test-content-removal.ts
|
|
1282
|
+
var RULE8 = "test-content-removal";
|
|
1283
|
+
var MIN_REMOVED_LINES = 3;
|
|
1284
|
+
var ws = (s) => s.replace(/\s+/g, "");
|
|
1285
|
+
function significantLinesOrdered(src) {
|
|
1286
|
+
const out = [];
|
|
1287
|
+
for (const raw of src.split("\n")) {
|
|
1288
|
+
const l = raw.trim();
|
|
1289
|
+
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out.push(l);
|
|
1290
|
+
}
|
|
1291
|
+
return out;
|
|
1292
|
+
}
|
|
1293
|
+
var testContentRemoval = {
|
|
1294
|
+
id: RULE8,
|
|
1295
|
+
surface: ["file"],
|
|
1296
|
+
certainty: "mechanical",
|
|
1297
|
+
run(changes, policy) {
|
|
1298
|
+
const out = [];
|
|
1299
|
+
let keptPool = "";
|
|
1300
|
+
for (const c of changes) {
|
|
1301
|
+
if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1302
|
+
keptPool += ws(significantLinesOrdered(c.after).join("\n"));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
for (const c of changes) {
|
|
1306
|
+
if (c.kind !== "file" || c.op !== "modify" || c.before == null || c.after == null) continue;
|
|
1307
|
+
if (!isProtected(c.path, policy, "tests")) continue;
|
|
1308
|
+
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
1309
|
+
if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
|
|
1310
|
+
const beforeSig = significantLinesOrdered(c.before);
|
|
1311
|
+
const afterSig = significantLinesOrdered(c.after);
|
|
1312
|
+
const netDrop = beforeSig.length - afterSig.length;
|
|
1313
|
+
const afterSet = new Set(afterSig);
|
|
1314
|
+
const gone = [];
|
|
1315
|
+
for (const line of new Set(beforeSig)) {
|
|
1316
|
+
if (afterSet.has(line)) continue;
|
|
1317
|
+
if (keptPool.includes(ws(line))) continue;
|
|
1318
|
+
gone.push(line);
|
|
1319
|
+
}
|
|
1320
|
+
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
1321
|
+
out.push(
|
|
1322
|
+
makeFinding(RULE8, policy, {
|
|
1323
|
+
file: c.path,
|
|
1324
|
+
message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
|
|
1325
|
+
evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
1326
|
+
remediation: "Fix the code under test. Deleting the failing rows, assertion arguments, or setup from a spec weakens it exactly like deleting the test \u2014 the block just keeps its name."
|
|
1327
|
+
})
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return out;
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1270
1335
|
// src/detectors/snapshot-rewrite.ts
|
|
1271
|
-
var
|
|
1336
|
+
var RULE9 = "snapshot-rewrite";
|
|
1272
1337
|
var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
1273
1338
|
var UPDATE_ANY = /--update-?[sS]napshots?\b/;
|
|
1274
1339
|
var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
|
|
@@ -1282,12 +1347,12 @@ function namesProtectedSnapshot(seg, policy) {
|
|
|
1282
1347
|
return null;
|
|
1283
1348
|
}
|
|
1284
1349
|
var snapshotRewrite = {
|
|
1285
|
-
id:
|
|
1350
|
+
id: RULE9,
|
|
1286
1351
|
surface: ["command", "file"],
|
|
1287
1352
|
certainty: "mechanical",
|
|
1288
1353
|
run(changes, policy) {
|
|
1289
1354
|
const out = [];
|
|
1290
|
-
const warn = (input) => out.push(makeFinding(
|
|
1355
|
+
const warn = (input) => out.push(makeFinding(RULE9, policy, { ...input, defaultSeverity: "warn" }));
|
|
1291
1356
|
for (const c of changes) {
|
|
1292
1357
|
if (c.kind === "command") {
|
|
1293
1358
|
for (const seg of segments(c.raw)) {
|
|
@@ -1325,7 +1390,7 @@ var snapshotRewrite = {
|
|
|
1325
1390
|
};
|
|
1326
1391
|
|
|
1327
1392
|
// src/detectors/snapshot-only.ts
|
|
1328
|
-
var
|
|
1393
|
+
var RULE10 = "snapshot-only-rewrite";
|
|
1329
1394
|
var COMMIT_VIEWS = ["staged", "range"];
|
|
1330
1395
|
function isSnapshotChange(c, policy) {
|
|
1331
1396
|
if (c.kind !== "file") return false;
|
|
@@ -1334,7 +1399,7 @@ function isSnapshotChange(c, policy) {
|
|
|
1334
1399
|
return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
|
|
1335
1400
|
}
|
|
1336
1401
|
var snapshotOnlyRewrite = {
|
|
1337
|
-
id:
|
|
1402
|
+
id: RULE10,
|
|
1338
1403
|
surface: ["file"],
|
|
1339
1404
|
certainty: "mechanical",
|
|
1340
1405
|
run(changes, policy, view) {
|
|
@@ -1346,7 +1411,7 @@ var snapshotOnlyRewrite = {
|
|
|
1346
1411
|
const first = snaps[0];
|
|
1347
1412
|
const names = snaps.map((c) => c.kind === "file" ? c.path : "").filter(Boolean).slice(0, 3).join(", ");
|
|
1348
1413
|
return [
|
|
1349
|
-
makeFinding(
|
|
1414
|
+
makeFinding(RULE10, policy, {
|
|
1350
1415
|
file: first.path,
|
|
1351
1416
|
message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
|
|
1352
1417
|
evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
|
|
@@ -1367,6 +1432,7 @@ var allDetectors = [
|
|
|
1367
1432
|
ciTampering,
|
|
1368
1433
|
hookTampering,
|
|
1369
1434
|
testDeletion,
|
|
1435
|
+
testContentRemoval,
|
|
1370
1436
|
snapshotRewrite,
|
|
1371
1437
|
snapshotOnlyRewrite
|
|
1372
1438
|
];
|
|
@@ -1396,12 +1462,17 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
|
1396
1462
|
}
|
|
1397
1463
|
}
|
|
1398
1464
|
const seen = /* @__PURE__ */ new Set();
|
|
1399
|
-
|
|
1465
|
+
const deduped = out.filter((f) => {
|
|
1400
1466
|
const k = key(f);
|
|
1401
1467
|
if (seen.has(k)) return false;
|
|
1402
1468
|
seen.add(k);
|
|
1403
1469
|
return true;
|
|
1404
1470
|
});
|
|
1471
|
+
return deduped.filter((f) => {
|
|
1472
|
+
const ex = policy.rules[f.rule]?.exclude;
|
|
1473
|
+
if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
|
|
1474
|
+
return !matchesAny(f.file, ex);
|
|
1475
|
+
});
|
|
1405
1476
|
}
|
|
1406
1477
|
function hasBlocking(findings) {
|
|
1407
1478
|
return findings.some((f) => f.severity === "block");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|