tamperward 1.6.0 → 1.8.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
@@ -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 hardens the `ts-any-launder` warn→block graduation case via the §7.A corpus —
233
- the honest fix itself narrows through `as Record<string, unknown>`, so a naive
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
@@ -258,8 +306,27 @@ severity tune → roughly one sign-off decision per 17 commits residual. The sam
258
306
  shipped per-rule `exclude` globs (1.6.0, with the policy-weakening symmetry built in)
259
307
  and then measured them honestly: **zero effect on this corpus** — these repos' casts
260
308
  live in source, not tests, so the zod-recommended path scoping removed nothing here;
261
- the null is published next to the feature. Running ledger: **56 pre-registered
262
- bets — 25 refuted, 29 held, 2 unresolvable.**
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.
263
330
 
264
331
  **What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
265
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: ["**/*.snap", "**/__snapshots__/**", "**/golden/**", "**/*.golden.*"],
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
@@ -457,6 +463,11 @@ function defaultPolicy(version = 1) {
457
463
  // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
458
464
  // when intended output changes. WARN until the §7 evidence path earns block.
459
465
  "snapshot-rewrite": { severity: "warn" },
466
+ // the watcher's transient class (modified-and-restored within a turn). WARN by
467
+ // default: a Stop block already forces restores, and a compliant restore reads
468
+ // as churn — block-by-default would deadlock the honest path. Harnesses that own
469
+ // that semantics raise it via TAMPERWARD_TRANSIENT=block.
470
+ "transient-protected-mutation": { severity: "warn" },
460
471
  // the narrow graduation candidate: snapshot moved with NO accompanying change.
461
472
  // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
462
473
  // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
@@ -1272,8 +1283,62 @@ var testDeletion = {
1272
1283
  }
1273
1284
  };
1274
1285
 
1286
+ // src/detectors/test-content-removal.ts
1287
+ var RULE8 = "test-content-removal";
1288
+ var MIN_REMOVED_LINES = 3;
1289
+ var ws = (s) => s.replace(/\s+/g, "");
1290
+ function significantLinesOrdered(src) {
1291
+ const out = [];
1292
+ for (const raw of src.split("\n")) {
1293
+ const l = raw.trim();
1294
+ if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out.push(l);
1295
+ }
1296
+ return out;
1297
+ }
1298
+ var testContentRemoval = {
1299
+ id: RULE8,
1300
+ surface: ["file"],
1301
+ certainty: "mechanical",
1302
+ run(changes, policy) {
1303
+ const out = [];
1304
+ let keptPool = "";
1305
+ for (const c of changes) {
1306
+ if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
1307
+ keptPool += ws(significantLinesOrdered(c.after).join("\n"));
1308
+ }
1309
+ }
1310
+ for (const c of changes) {
1311
+ if (c.kind !== "file" || c.op !== "modify" || c.before == null || c.after == null) continue;
1312
+ if (!isProtected(c.path, policy, "tests")) continue;
1313
+ if (isProtected(c.path, policy, "snapshots")) continue;
1314
+ if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
1315
+ const beforeSig = significantLinesOrdered(c.before);
1316
+ const afterSig = significantLinesOrdered(c.after);
1317
+ const netDrop = beforeSig.length - afterSig.length;
1318
+ const afterSet = new Set(afterSig);
1319
+ const gone = [];
1320
+ for (const line of new Set(beforeSig)) {
1321
+ if (afterSet.has(line)) continue;
1322
+ if (keptPool.includes(ws(line))) continue;
1323
+ gone.push(line);
1324
+ }
1325
+ if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
1326
+ out.push(
1327
+ makeFinding(RULE8, policy, {
1328
+ file: c.path,
1329
+ message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
1330
+ evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
1331
+ 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."
1332
+ })
1333
+ );
1334
+ }
1335
+ }
1336
+ return out;
1337
+ }
1338
+ };
1339
+
1275
1340
  // src/detectors/snapshot-rewrite.ts
1276
- var RULE8 = "snapshot-rewrite";
1341
+ var RULE9 = "snapshot-rewrite";
1277
1342
  var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
1278
1343
  var UPDATE_ANY = /--update-?[sS]napshots?\b/;
1279
1344
  var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
@@ -1287,12 +1352,12 @@ function namesProtectedSnapshot(seg, policy) {
1287
1352
  return null;
1288
1353
  }
1289
1354
  var snapshotRewrite = {
1290
- id: RULE8,
1355
+ id: RULE9,
1291
1356
  surface: ["command", "file"],
1292
1357
  certainty: "mechanical",
1293
1358
  run(changes, policy) {
1294
1359
  const out = [];
1295
- const warn = (input) => out.push(makeFinding(RULE8, policy, { ...input, defaultSeverity: "warn" }));
1360
+ const warn = (input) => out.push(makeFinding(RULE9, policy, { ...input, defaultSeverity: "warn" }));
1296
1361
  for (const c of changes) {
1297
1362
  if (c.kind === "command") {
1298
1363
  for (const seg of segments(c.raw)) {
@@ -1330,7 +1395,7 @@ var snapshotRewrite = {
1330
1395
  };
1331
1396
 
1332
1397
  // src/detectors/snapshot-only.ts
1333
- var RULE9 = "snapshot-only-rewrite";
1398
+ var RULE10 = "snapshot-only-rewrite";
1334
1399
  var COMMIT_VIEWS = ["staged", "range"];
1335
1400
  function isSnapshotChange(c, policy) {
1336
1401
  if (c.kind !== "file") return false;
@@ -1339,7 +1404,7 @@ function isSnapshotChange(c, policy) {
1339
1404
  return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
1340
1405
  }
1341
1406
  var snapshotOnlyRewrite = {
1342
- id: RULE9,
1407
+ id: RULE10,
1343
1408
  surface: ["file"],
1344
1409
  certainty: "mechanical",
1345
1410
  run(changes, policy, view) {
@@ -1351,7 +1416,7 @@ var snapshotOnlyRewrite = {
1351
1416
  const first = snaps[0];
1352
1417
  const names = snaps.map((c) => c.kind === "file" ? c.path : "").filter(Boolean).slice(0, 3).join(", ");
1353
1418
  return [
1354
- makeFinding(RULE9, policy, {
1419
+ makeFinding(RULE10, policy, {
1355
1420
  file: first.path,
1356
1421
  message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
1357
1422
  evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
@@ -1372,6 +1437,7 @@ var allDetectors = [
1372
1437
  ciTampering,
1373
1438
  hookTampering,
1374
1439
  testDeletion,
1440
+ testContentRemoval,
1375
1441
  snapshotRewrite,
1376
1442
  snapshotOnlyRewrite
1377
1443
  ];
@@ -1660,10 +1726,10 @@ function locationCell(f, env) {
1660
1726
  const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1661
1727
  const server = env.GITHUB_SERVER_URL;
1662
1728
  const repo = env.GITHUB_REPOSITORY;
1663
- const sha = env.GITHUB_SHA;
1664
- if (!server || !repo || !sha) return `\`${cell(label)}\``;
1729
+ const sha2 = env.GITHUB_SHA;
1730
+ if (!server || !repo || !sha2) return `\`${cell(label)}\``;
1665
1731
  const frag = f.line ? `#L${f.line}` : "";
1666
- return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
1732
+ return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
1667
1733
  }
1668
1734
  function renderSummary(input, env = process.env) {
1669
1735
  const findings = orderFindings(input.findings);
@@ -1809,7 +1875,8 @@ function runCheck(opts) {
1809
1875
  }
1810
1876
 
1811
1877
  // src/cli/hook.ts
1812
- import { readFileSync as readFileSync6, appendFileSync as appendFileSync3 } from "node:fs";
1878
+ import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
1879
+ import { createHash as createHash4 } from "node:crypto";
1813
1880
 
1814
1881
  // src/adapters/claude/changes.ts
1815
1882
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -1834,6 +1901,7 @@ function relForDisplay(path, cwd) {
1834
1901
  }
1835
1902
  function applyEdit(content, oldStr, newStr) {
1836
1903
  if (content === null) return newStr;
1904
+ if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
1837
1905
  return content.replace(oldStr, () => newStr);
1838
1906
  }
1839
1907
  function synthFileChange(displayPath, before, after) {
@@ -1964,10 +2032,261 @@ function advanceTurnBaseline(cwd, sessionId) {
1964
2032
  }
1965
2033
  }
1966
2034
 
2035
+ // src/effect.ts
2036
+ import { createHash as createHash2 } from "node:crypto";
2037
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2038
+ import { join as join6 } from "node:path";
2039
+ import { execFileSync as execFileSync3 } from "node:child_process";
2040
+ var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2041
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
2042
+ function gitDir2(cwd) {
2043
+ try {
2044
+ return execFileSync3("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
2045
+ } catch {
2046
+ return null;
2047
+ }
2048
+ }
2049
+ function ptreePath(cwd, sessionId) {
2050
+ if (!sessionId) return null;
2051
+ const gd = gitDir2(cwd);
2052
+ if (!gd) return null;
2053
+ const dir = join6(cwd, gd, "tamperward");
2054
+ return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
2055
+ }
2056
+ var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
2057
+ function snapshotProtected(cwd, policy, prev) {
2058
+ const out = {};
2059
+ const walk = (rel) => {
2060
+ const abs2 = rel ? join6(cwd, rel) : cwd;
2061
+ let names;
2062
+ try {
2063
+ names = readdirSync(abs2);
2064
+ } catch {
2065
+ return;
2066
+ }
2067
+ for (const name of names) {
2068
+ if (SKIP_DIRS.has(name)) continue;
2069
+ const r = rel ? `${rel}/${name}` : name;
2070
+ let st;
2071
+ try {
2072
+ st = statSync(join6(cwd, r));
2073
+ } catch {
2074
+ continue;
2075
+ }
2076
+ if (st.isDirectory()) walk(r);
2077
+ else if (st.isFile() && isProtected(r, policy)) {
2078
+ const p = prev?.[r];
2079
+ if (p && p.mtimeMs === st.mtimeMs && p.size === st.size && p.mode === st.mode) {
2080
+ out[r] = p;
2081
+ } else {
2082
+ let hash = "";
2083
+ try {
2084
+ hash = sha(readFileSync6(join6(cwd, r)));
2085
+ } catch {
2086
+ hash = "unreadable";
2087
+ }
2088
+ out[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
2089
+ }
2090
+ }
2091
+ }
2092
+ };
2093
+ walk("");
2094
+ return out;
2095
+ }
2096
+ function driftBetween(expected, current) {
2097
+ const changed = [];
2098
+ const deleted = [];
2099
+ for (const [path, e] of Object.entries(expected)) {
2100
+ const c = current[path];
2101
+ if (!c) deleted.push(path);
2102
+ else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
2103
+ }
2104
+ return { changed, deleted };
2105
+ }
2106
+ function loadPtree(cwd, sessionId) {
2107
+ const p = ptreePath(cwd, sessionId);
2108
+ if (!p || !existsSync5(p)) return null;
2109
+ try {
2110
+ return JSON.parse(readFileSync6(p, "utf8"));
2111
+ } catch {
2112
+ return null;
2113
+ }
2114
+ }
2115
+ function savePtree(cwd, sessionId, tree) {
2116
+ const p = ptreePath(cwd, sessionId);
2117
+ if (!p) return;
2118
+ try {
2119
+ mkdirSync3(join6(p, ".."), { recursive: true });
2120
+ writeFileSync3(p, JSON.stringify(tree));
2121
+ } catch {
2122
+ }
2123
+ }
2124
+
2125
+ // src/cli/watch.ts
2126
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2127
+ import { join as join7 } from "node:path";
2128
+ import { createHash as createHash3 } from "node:crypto";
2129
+ var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
2130
+ function snap(abs2) {
2131
+ try {
2132
+ const st = statSync2(abs2);
2133
+ if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
2134
+ const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
2135
+ return { mode: st.mode, size: st.size, hash };
2136
+ } catch {
2137
+ return { mode: null, size: null, hash: null };
2138
+ }
2139
+ }
2140
+ function defaultEventLog(cwd) {
2141
+ return process.env.TAMPERWARD_FSEVENTS ?? join7(cwd, ".git", "tamperward", "fsevents.jsonl");
2142
+ }
2143
+ function watchTree(dir, cb) {
2144
+ if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
2145
+ try {
2146
+ const w = watch(dir, { recursive: true }, (kind, fname) => {
2147
+ if (fname) cb(kind, String(fname));
2148
+ });
2149
+ return { close: () => w.close() };
2150
+ } catch {
2151
+ }
2152
+ }
2153
+ const watchers = /* @__PURE__ */ new Map();
2154
+ const addDir = (rel) => {
2155
+ if (watchers.has(rel) || SKIP.test(rel + "/")) return;
2156
+ let w;
2157
+ try {
2158
+ w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
2159
+ if (!fname) return;
2160
+ const child = rel ? `${rel}/${String(fname)}` : String(fname);
2161
+ cb(kind, child);
2162
+ try {
2163
+ if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
2164
+ } catch {
2165
+ }
2166
+ });
2167
+ } catch {
2168
+ return;
2169
+ }
2170
+ watchers.set(rel, w);
2171
+ };
2172
+ const walk = (rel) => {
2173
+ addDir(rel);
2174
+ let names = [];
2175
+ try {
2176
+ names = readdirSync2(rel ? join7(dir, rel) : dir);
2177
+ } catch {
2178
+ return;
2179
+ }
2180
+ for (const name of names) {
2181
+ const child = rel ? `${rel}/${name}` : name;
2182
+ if (SKIP.test(child + "/")) continue;
2183
+ try {
2184
+ if (statSync2(join7(dir, child)).isDirectory()) walk(child);
2185
+ } catch {
2186
+ }
2187
+ }
2188
+ };
2189
+ walk("");
2190
+ return { close: () => {
2191
+ for (const w of watchers.values()) w.close();
2192
+ } };
2193
+ }
2194
+ function startWatcher(dir, log, policy) {
2195
+ try {
2196
+ mkdirSync4(join7(log, ".."), { recursive: true });
2197
+ } catch {
2198
+ }
2199
+ const last = /* @__PURE__ */ new Map();
2200
+ return watchTree(dir, (kind, rel) => {
2201
+ if (SKIP.test(rel) || !isProtected(rel, policy)) return;
2202
+ const s = snap(join7(dir, rel));
2203
+ const key2 = `${s.hash}:${s.mode}`;
2204
+ if (last.get(rel) === key2) return;
2205
+ last.set(rel, key2);
2206
+ const ev = { ts: (/* @__PURE__ */ new Date()).toISOString(), path: rel, kind: kind === "rename" ? "rename" : "change", ...s };
2207
+ try {
2208
+ appendFileSync3(log, JSON.stringify(ev) + "\n");
2209
+ } catch {
2210
+ }
2211
+ });
2212
+ }
2213
+ function runWatch(args) {
2214
+ let dir = process.cwd();
2215
+ let log = null;
2216
+ for (let i = 0; i < args.length; i++) {
2217
+ if (args[i] === "--dir" && args[i + 1]) dir = args[++i];
2218
+ else if (args[i] === "--log" && args[i + 1]) log = args[++i];
2219
+ }
2220
+ const policy = loadPolicy(dir);
2221
+ const out = log ?? defaultEventLog(dir);
2222
+ startWatcher(dir, out, policy);
2223
+ process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out}
2224
+ `);
2225
+ return -1;
2226
+ }
2227
+
2228
+ // src/detectors/fs-events.ts
2229
+ import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
2230
+ var RULE11 = "transient-protected-mutation";
2231
+ function readEvents(log, offset) {
2232
+ let size = 0;
2233
+ try {
2234
+ size = statSync3(log).size;
2235
+ } catch {
2236
+ return { events: [], newOffset: offset };
2237
+ }
2238
+ if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
2239
+ const raw = readFileSync8(log, "utf8").slice(offset);
2240
+ const events = [];
2241
+ for (const line of raw.split("\n")) {
2242
+ if (!line.trim()) continue;
2243
+ try {
2244
+ events.push(JSON.parse(line));
2245
+ } catch {
2246
+ }
2247
+ }
2248
+ return { events, newOffset: size };
2249
+ }
2250
+ function transientFindings(events, persistentPaths, policy, finalHash) {
2251
+ const byPath = /* @__PURE__ */ new Map();
2252
+ for (const e of events) {
2253
+ if (!isProtected(e.path, policy)) continue;
2254
+ const arr = byPath.get(e.path) ?? [];
2255
+ arr.push(e);
2256
+ byPath.set(e.path, arr);
2257
+ }
2258
+ const out = [];
2259
+ const strict = process.env.TAMPERWARD_TRANSIENT === "block";
2260
+ for (const [path, evs] of byPath) {
2261
+ if (persistentPaths.has(path)) continue;
2262
+ const final = finalHash(path);
2263
+ const hashes = new Set(evs.map((e) => e.hash).filter((h) => h != null));
2264
+ if (final != null) hashes.add(final);
2265
+ const contentChurn = hashes.size > 1;
2266
+ const modes = new Set(evs.map((e) => e.mode).filter((m) => m != null));
2267
+ const modeChurn = modes.size > 1;
2268
+ if (!contentChurn && !modeChurn) continue;
2269
+ const what = contentChurn ? `content was modified and restored within the turn (${hashes.size} distinct states observed)` : `file mode changed during the turn (${[...modes].map((m) => (m & 4095).toString(8)).join(" \u2192 ")})`;
2270
+ const f = makeFinding(RULE11, policy, {
2271
+ file: path,
2272
+ message: `Transient protected mutation: ${what}, leaving no net diff.`,
2273
+ evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
2274
+ remediation: "Anything that ran against the mid-turn state ran against a weakened suite. Re-run the checks against the restored tree; if the mutation was an experiment, say so where a human will read it.",
2275
+ defaultSeverity: "warn"
2276
+ });
2277
+ if (strict && f.severity !== "block") {
2278
+ f.severity = "block";
2279
+ f.signoff.required = true;
2280
+ }
2281
+ out.push(f);
2282
+ }
2283
+ return out;
2284
+ }
2285
+
1967
2286
  // src/cli/hook.ts
1968
2287
  function readStdin() {
1969
2288
  try {
1970
- return readFileSync6(0, "utf8");
2289
+ return readFileSync9(0, "utf8");
1971
2290
  } catch {
1972
2291
  return "";
1973
2292
  }
@@ -1984,7 +2303,7 @@ function recordDenylog(blocks) {
1984
2303
  const log = process.env.TAMPERWARD_DENYLOG;
1985
2304
  if (!log) return;
1986
2305
  try {
1987
- appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
2306
+ appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
1988
2307
  } catch {
1989
2308
  }
1990
2309
  }
@@ -2016,30 +2335,141 @@ function verdict(blocks, kind) {
2016
2335
  } : { decision: "block", reason };
2017
2336
  return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
2018
2337
  }
2338
+ function effectDriftBlocks(cwd, sessionId, policy) {
2339
+ if (!sessionId || !isGitRepo(cwd)) return null;
2340
+ const expected = loadPtree(cwd, sessionId);
2341
+ const current = snapshotProtected(cwd, policy, expected ?? void 0);
2342
+ if (!expected) {
2343
+ savePtree(cwd, sessionId, current);
2344
+ return null;
2345
+ }
2346
+ const d = driftBetween(expected, current);
2347
+ if (d.changed.length === 0 && d.deleted.length === 0) {
2348
+ savePtree(cwd, sessionId, current);
2349
+ return null;
2350
+ }
2351
+ const base = turnBaseline(cwd, sessionId);
2352
+ const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
2353
+ const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
2354
+ const scoped = all.filter(
2355
+ (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
2356
+ );
2357
+ const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
2358
+ if (blocks.length > 0) return blocks;
2359
+ savePtree(cwd, sessionId, current);
2360
+ return null;
2361
+ }
2362
+ function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
2363
+ if (!sessionId) return;
2364
+ const tree = loadPtree(cwd, sessionId);
2365
+ if (!tree) return;
2366
+ let dirty = false;
2367
+ for (const c of changes) {
2368
+ if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
2369
+ const hash = createHash4("sha256").update(c.after).digest("hex").slice(0, 16);
2370
+ const prev = tree[c.path];
2371
+ tree[c.path] = {
2372
+ hash,
2373
+ mode: prev?.mode ?? 33188,
2374
+ size: Buffer.byteLength(c.after),
2375
+ mtimeMs: -1
2376
+ // unknown until written; forces a re-hash next snapshot, which is correct
2377
+ };
2378
+ dirty = true;
2379
+ }
2380
+ if (dirty) savePtree(cwd, sessionId, tree);
2381
+ }
2019
2382
  function preToolUseVerdict(input) {
2020
2383
  try {
2021
2384
  const cwd = input.cwd ?? process.cwd();
2022
2385
  turnBaseline(cwd, input.session_id);
2386
+ const policy = loadPolicy(cwd);
2387
+ const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
2388
+ if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
2023
2389
  const changes = changesFromClaudeHook(input, cwd);
2024
- const blocks = evaluate(changes, loadPolicy(cwd), void 0, "tool-call").filter((f) => f.severity === "block");
2390
+ const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
2391
+ if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
2025
2392
  return verdict(blocks, "PreToolUse");
2026
2393
  } catch (e) {
2027
2394
  return failClosed("PreToolUse", errText(e));
2028
2395
  }
2029
2396
  }
2397
+ function cursorPath(cwd, sessionId) {
2398
+ const p = ptreePath(cwd, sessionId);
2399
+ return p ? p.replace(/ptree-/, "fscursor-") : null;
2400
+ }
2401
+ function turnTransientBlocks(cwd, sessionId, policy, changes) {
2402
+ const none = { blocks: [], commit: () => {
2403
+ } };
2404
+ const log = defaultEventLog(cwd);
2405
+ const cp = cursorPath(cwd, sessionId);
2406
+ if (!existsSync6(log)) return none;
2407
+ let offset = 0;
2408
+ if (cp && existsSync6(cp)) {
2409
+ const n = Number(readFileSync9(cp, "utf8"));
2410
+ offset = Number.isFinite(n) ? n : 0;
2411
+ }
2412
+ const { events, newOffset } = readEvents(log, offset);
2413
+ if (events.length === 0) return { ...none, commit: () => {
2414
+ if (cp) try {
2415
+ writeFileSync4(cp, String(newOffset));
2416
+ } catch {
2417
+ }
2418
+ } };
2419
+ const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
2420
+ const finalHash = (path) => {
2421
+ try {
2422
+ if (!statSync4(`${cwd}/${path}`).isFile()) return null;
2423
+ return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
2424
+ } catch {
2425
+ return null;
2426
+ }
2427
+ };
2428
+ const findings = transientFindings(events, persistent, policy, finalHash);
2429
+ recordWarns(findings.filter((f) => f.severity !== "block"));
2430
+ return {
2431
+ blocks: findings.filter((f) => f.severity === "block"),
2432
+ commit: () => {
2433
+ if (cp) try {
2434
+ writeFileSync4(cp, String(newOffset));
2435
+ } catch {
2436
+ }
2437
+ }
2438
+ };
2439
+ }
2440
+ function recordWarns(warns) {
2441
+ const log = process.env.TAMPERWARD_DENYLOG;
2442
+ if (!log || warns.length === 0) return;
2443
+ try {
2444
+ appendFileSync4(log, warns.map((w) => `warn:${w.rule}:${w.file ?? ""}`).join("\n") + "\n");
2445
+ } catch {
2446
+ }
2447
+ }
2030
2448
  function stopVerdict(input) {
2031
2449
  if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
2032
2450
  const cwd = input.cwd ?? process.cwd();
2033
2451
  if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
2034
2452
  let blocks;
2453
+ let commitCursor = () => {
2454
+ };
2035
2455
  try {
2456
+ const policy = loadPolicy(cwd);
2036
2457
  const base = turnBaseline(cwd, input.session_id);
2037
2458
  const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
2038
- blocks = evaluate(changes, loadPolicy(cwd), void 0, "turn").filter((f) => f.severity === "block");
2459
+ blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
2460
+ const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
2461
+ blocks = blocks.concat(transient.blocks);
2462
+ commitCursor = transient.commit;
2463
+ if (blocks.length === 0 && input.session_id) {
2464
+ savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
2465
+ }
2039
2466
  } catch (e) {
2040
2467
  return failClosed("Stop", errText(e));
2041
2468
  }
2042
- if (blocks.length === 0) advanceTurnBaseline(cwd, input.session_id);
2469
+ if (blocks.length === 0) {
2470
+ advanceTurnBaseline(cwd, input.session_id);
2471
+ commitCursor();
2472
+ }
2043
2473
  return verdict(blocks, "Stop");
2044
2474
  }
2045
2475
  function emit(r) {
@@ -2108,8 +2538,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2108
2538
  }
2109
2539
 
2110
2540
  // src/cli/init.ts
2111
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3, chmodSync } from "node:fs";
2112
- import { dirname as dirname3, join as join6 } from "node:path";
2541
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2542
+ import { dirname as dirname3, join as join8 } from "node:path";
2113
2543
  var HOOK_CMD = "npx --yes tamperward hook claude";
2114
2544
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2115
2545
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
@@ -2169,24 +2599,24 @@ jobs:
2169
2599
  run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2170
2600
  `;
2171
2601
  function planPolicy(cwd) {
2172
- const path = join6(cwd, POLICY_FILE);
2173
- if (existsSync5(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
2602
+ const path = join8(cwd, POLICY_FILE);
2603
+ if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
2174
2604
  return {
2175
2605
  item: "policy",
2176
2606
  path: POLICY_FILE,
2177
2607
  status: "create",
2178
2608
  detail: "baseline policy with commented overrides",
2179
- apply: () => writeFileSync3(path, POLICY_CONTENT)
2609
+ apply: () => writeFileSync5(path, POLICY_CONTENT)
2180
2610
  };
2181
2611
  }
2182
2612
  function planClaudeHooks(cwd) {
2183
2613
  const rel = ".claude/settings.json";
2184
- const path = join6(cwd, rel);
2614
+ const path = join8(cwd, rel);
2185
2615
  let settings = {};
2186
- if (existsSync5(path)) {
2616
+ if (existsSync7(path)) {
2187
2617
  let parsed;
2188
2618
  try {
2189
- parsed = JSON.parse(readFileSync7(path, "utf8"));
2619
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
2190
2620
  } catch {
2191
2621
  return {
2192
2622
  item: "agent",
@@ -2208,7 +2638,7 @@ function planClaudeHooks(cwd) {
2208
2638
  return {
2209
2639
  item: "agent",
2210
2640
  path: rel,
2211
- status: existsSync5(path) ? "update" : "create",
2641
+ status: existsSync7(path) ? "update" : "create",
2212
2642
  detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
2213
2643
  apply: () => {
2214
2644
  if (needPre) {
@@ -2220,8 +2650,8 @@ function planClaudeHooks(cwd) {
2220
2650
  if (needStop) {
2221
2651
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
2222
2652
  }
2223
- mkdirSync3(dirname3(path), { recursive: true });
2224
- writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n");
2653
+ mkdirSync5(dirname3(path), { recursive: true });
2654
+ writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
2225
2655
  }
2226
2656
  };
2227
2657
  }
@@ -2229,12 +2659,12 @@ function planPreCommit(cwd) {
2229
2659
  const line = `${MARKER}
2230
2660
  ${PRECOMMIT_CMD}
2231
2661
  `;
2232
- const husky = join6(cwd, ".husky");
2233
- const gitDir2 = join6(cwd, ".git");
2234
- 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;
2662
+ const husky = join8(cwd, ".husky");
2663
+ const gitDir3 = join8(cwd, ".git");
2664
+ const target = existsSync7(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync7(gitDir3) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
2235
2665
  if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
2236
- const path = join6(cwd, target.rel);
2237
- const existing = existsSync5(path) ? readFileSync7(path, "utf8") : null;
2666
+ const path = join8(cwd, target.rel);
2667
+ const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
2238
2668
  if (existing?.includes("tamperward check --staged")) {
2239
2669
  return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
2240
2670
  }
@@ -2244,26 +2674,26 @@ ${PRECOMMIT_CMD}
2244
2674
  status: existing === null ? "create" : "update",
2245
2675
  detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
2246
2676
  apply: () => {
2247
- mkdirSync3(dirname3(path), { recursive: true });
2677
+ mkdirSync5(dirname3(path), { recursive: true });
2248
2678
  const content = existing === null ? `#!/bin/sh
2249
2679
  ${line}` : existing.replace(/\n?$/, "\n") + line;
2250
- writeFileSync3(path, content);
2680
+ writeFileSync5(path, content);
2251
2681
  chmodSync(path, 493);
2252
2682
  }
2253
2683
  };
2254
2684
  }
2255
2685
  function planWorkflow(cwd) {
2256
2686
  const rel = ".github/workflows/tamperward.yml";
2257
- const path = join6(cwd, rel);
2258
- if (existsSync5(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2687
+ const path = join8(cwd, rel);
2688
+ if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2259
2689
  return {
2260
2690
  item: "ci",
2261
2691
  path: rel,
2262
2692
  status: "create",
2263
2693
  detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
2264
2694
  apply: () => {
2265
- mkdirSync3(dirname3(path), { recursive: true });
2266
- writeFileSync3(path, WORKFLOW_CONTENT);
2695
+ mkdirSync5(dirname3(path), { recursive: true });
2696
+ writeFileSync5(path, WORKFLOW_CONTENT);
2267
2697
  }
2268
2698
  };
2269
2699
  }
@@ -2370,6 +2800,9 @@ Formats:
2370
2800
  auto github when GITHUB_ACTIONS=true, otherwise text.
2371
2801
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
2372
2802
  tamperward sweep claude Stop sweep (re-scan the turn's working tree)
2803
+ tamperward watch [--dir D] [--log F] filesystem-event observer daemon: records
2804
+ protected-file events so the sweep can
2805
+ observe supported transient effects
2373
2806
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
2374
2807
  tamperward init [--dry-run] wire the policy file plus every
2375
2808
  enforcement point: Claude Code hooks,
@@ -2380,8 +2813,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
2380
2813
  emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
2381
2814
  `);
2382
2815
  }
2383
- function main(argv) {
2384
- const [cmd, ...rest] = argv;
2816
+ function main(argv2) {
2817
+ const [cmd, ...rest] = argv2;
2385
2818
  switch (cmd) {
2386
2819
  case "check":
2387
2820
  return runCheck(parseCheck(rest));
@@ -2405,4 +2838,9 @@ function main(argv) {
2405
2838
  return 2;
2406
2839
  }
2407
2840
  }
2408
- process.exit(main(process.argv.slice(2)));
2841
+ var argv = process.argv.slice(2);
2842
+ if (argv[0] === "watch") {
2843
+ runWatch(argv.slice(1));
2844
+ } else {
2845
+ process.exit(main(argv));
2846
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",