tamperward 1.7.0 → 1.9.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.
Files changed (2) hide show
  1. package/dist/cli/index.js +603 -35
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -463,6 +463,11 @@ function defaultPolicy(version = 1) {
463
463
  // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
464
464
  // when intended output changes. WARN until the §7 evidence path earns block.
465
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" },
466
471
  // the narrow graduation candidate: snapshot moved with NO accompanying change.
467
472
  // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
468
473
  // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
@@ -1009,6 +1014,24 @@ function policyWeakening(before, after) {
1009
1014
  if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
1010
1015
  reasons.push("sign-off no longer required for blocking findings");
1011
1016
  }
1017
+ const bv = b.verify;
1018
+ const av = a.verify;
1019
+ if (bv?.command) {
1020
+ if (!av?.command) {
1021
+ reasons.push("verify block removed \u2014 pristine-suite re-execution disabled");
1022
+ } else {
1023
+ if (av.command !== bv.command) {
1024
+ reasons.push(
1025
+ `verify.command changed ("${bv.command}" \u2192 "${av.command}") \u2014 the re-executed suite is no longer the one that was configured`
1026
+ );
1027
+ }
1028
+ const bb = bv.budget ?? 300;
1029
+ const ab = av.budget ?? 300;
1030
+ if (ab < bb) {
1031
+ reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
1032
+ }
1033
+ }
1034
+ }
1012
1035
  return reasons;
1013
1036
  }
1014
1037
 
@@ -1506,7 +1529,8 @@ function parsePolicy(raw) {
1506
1529
  signoff: {
1507
1530
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1508
1531
  ledger: r.signoff?.ledger ?? base.signoff.ledger
1509
- }
1532
+ },
1533
+ ...r.verify?.command ? { verify: { command: r.verify.command, budget: r.verify.budget ?? 300 } } : {}
1510
1534
  };
1511
1535
  }
1512
1536
  function parseOrThrow(src, where) {
@@ -1721,10 +1745,10 @@ function locationCell(f, env) {
1721
1745
  const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1722
1746
  const server = env.GITHUB_SERVER_URL;
1723
1747
  const repo = env.GITHUB_REPOSITORY;
1724
- const sha = env.GITHUB_SHA;
1725
- if (!server || !repo || !sha) return `\`${cell(label)}\``;
1748
+ const sha2 = env.GITHUB_SHA;
1749
+ if (!server || !repo || !sha2) return `\`${cell(label)}\``;
1726
1750
  const frag = f.line ? `#L${f.line}` : "";
1727
- return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
1751
+ return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
1728
1752
  }
1729
1753
  function renderSummary(input, env = process.env) {
1730
1754
  const findings = orderFindings(input.findings);
@@ -1870,7 +1894,8 @@ function runCheck(opts) {
1870
1894
  }
1871
1895
 
1872
1896
  // src/cli/hook.ts
1873
- import { readFileSync as readFileSync6, appendFileSync as appendFileSync3 } from "node:fs";
1897
+ import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
1898
+ import { createHash as createHash4 } from "node:crypto";
1874
1899
 
1875
1900
  // src/adapters/claude/changes.ts
1876
1901
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -1895,6 +1920,7 @@ function relForDisplay(path, cwd) {
1895
1920
  }
1896
1921
  function applyEdit(content, oldStr, newStr) {
1897
1922
  if (content === null) return newStr;
1923
+ if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
1898
1924
  return content.replace(oldStr, () => newStr);
1899
1925
  }
1900
1926
  function synthFileChange(displayPath, before, after) {
@@ -2025,10 +2051,261 @@ function advanceTurnBaseline(cwd, sessionId) {
2025
2051
  }
2026
2052
  }
2027
2053
 
2054
+ // src/effect.ts
2055
+ import { createHash as createHash2 } from "node:crypto";
2056
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2057
+ import { join as join6 } from "node:path";
2058
+ import { execFileSync as execFileSync3 } from "node:child_process";
2059
+ var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2060
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
2061
+ function gitDir2(cwd) {
2062
+ try {
2063
+ return execFileSync3("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
2064
+ } catch {
2065
+ return null;
2066
+ }
2067
+ }
2068
+ function ptreePath(cwd, sessionId) {
2069
+ if (!sessionId) return null;
2070
+ const gd = gitDir2(cwd);
2071
+ if (!gd) return null;
2072
+ const dir = join6(cwd, gd, "tamperward");
2073
+ return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
2074
+ }
2075
+ var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
2076
+ function snapshotProtected(cwd, policy, prev) {
2077
+ const out = {};
2078
+ const walk = (rel) => {
2079
+ const abs2 = rel ? join6(cwd, rel) : cwd;
2080
+ let names;
2081
+ try {
2082
+ names = readdirSync(abs2);
2083
+ } catch {
2084
+ return;
2085
+ }
2086
+ for (const name of names) {
2087
+ if (SKIP_DIRS.has(name)) continue;
2088
+ const r = rel ? `${rel}/${name}` : name;
2089
+ let st;
2090
+ try {
2091
+ st = statSync(join6(cwd, r));
2092
+ } catch {
2093
+ continue;
2094
+ }
2095
+ if (st.isDirectory()) walk(r);
2096
+ else if (st.isFile() && isProtected(r, policy)) {
2097
+ const p = prev?.[r];
2098
+ if (p && p.mtimeMs === st.mtimeMs && p.size === st.size && p.mode === st.mode) {
2099
+ out[r] = p;
2100
+ } else {
2101
+ let hash = "";
2102
+ try {
2103
+ hash = sha(readFileSync6(join6(cwd, r)));
2104
+ } catch {
2105
+ hash = "unreadable";
2106
+ }
2107
+ out[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
2108
+ }
2109
+ }
2110
+ }
2111
+ };
2112
+ walk("");
2113
+ return out;
2114
+ }
2115
+ function driftBetween(expected, current) {
2116
+ const changed = [];
2117
+ const deleted = [];
2118
+ for (const [path, e] of Object.entries(expected)) {
2119
+ const c = current[path];
2120
+ if (!c) deleted.push(path);
2121
+ else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
2122
+ }
2123
+ return { changed, deleted };
2124
+ }
2125
+ function loadPtree(cwd, sessionId) {
2126
+ const p = ptreePath(cwd, sessionId);
2127
+ if (!p || !existsSync5(p)) return null;
2128
+ try {
2129
+ return JSON.parse(readFileSync6(p, "utf8"));
2130
+ } catch {
2131
+ return null;
2132
+ }
2133
+ }
2134
+ function savePtree(cwd, sessionId, tree) {
2135
+ const p = ptreePath(cwd, sessionId);
2136
+ if (!p) return;
2137
+ try {
2138
+ mkdirSync3(join6(p, ".."), { recursive: true });
2139
+ writeFileSync3(p, JSON.stringify(tree));
2140
+ } catch {
2141
+ }
2142
+ }
2143
+
2144
+ // src/cli/watch.ts
2145
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2146
+ import { join as join7 } from "node:path";
2147
+ import { createHash as createHash3 } from "node:crypto";
2148
+ var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
2149
+ function snap(abs2) {
2150
+ try {
2151
+ const st = statSync2(abs2);
2152
+ if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
2153
+ const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
2154
+ return { mode: st.mode, size: st.size, hash };
2155
+ } catch {
2156
+ return { mode: null, size: null, hash: null };
2157
+ }
2158
+ }
2159
+ function defaultEventLog(cwd) {
2160
+ return process.env.TAMPERWARD_FSEVENTS ?? join7(cwd, ".git", "tamperward", "fsevents.jsonl");
2161
+ }
2162
+ function watchTree(dir, cb) {
2163
+ if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
2164
+ try {
2165
+ const w = watch(dir, { recursive: true }, (kind, fname) => {
2166
+ if (fname) cb(kind, String(fname));
2167
+ });
2168
+ return { close: () => w.close() };
2169
+ } catch {
2170
+ }
2171
+ }
2172
+ const watchers = /* @__PURE__ */ new Map();
2173
+ const addDir = (rel) => {
2174
+ if (watchers.has(rel) || SKIP.test(rel + "/")) return;
2175
+ let w;
2176
+ try {
2177
+ w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
2178
+ if (!fname) return;
2179
+ const child = rel ? `${rel}/${String(fname)}` : String(fname);
2180
+ cb(kind, child);
2181
+ try {
2182
+ if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
2183
+ } catch {
2184
+ }
2185
+ });
2186
+ } catch {
2187
+ return;
2188
+ }
2189
+ watchers.set(rel, w);
2190
+ };
2191
+ const walk = (rel) => {
2192
+ addDir(rel);
2193
+ let names = [];
2194
+ try {
2195
+ names = readdirSync2(rel ? join7(dir, rel) : dir);
2196
+ } catch {
2197
+ return;
2198
+ }
2199
+ for (const name of names) {
2200
+ const child = rel ? `${rel}/${name}` : name;
2201
+ if (SKIP.test(child + "/")) continue;
2202
+ try {
2203
+ if (statSync2(join7(dir, child)).isDirectory()) walk(child);
2204
+ } catch {
2205
+ }
2206
+ }
2207
+ };
2208
+ walk("");
2209
+ return { close: () => {
2210
+ for (const w of watchers.values()) w.close();
2211
+ } };
2212
+ }
2213
+ function startWatcher(dir, log, policy) {
2214
+ try {
2215
+ mkdirSync4(join7(log, ".."), { recursive: true });
2216
+ } catch {
2217
+ }
2218
+ const last = /* @__PURE__ */ new Map();
2219
+ return watchTree(dir, (kind, rel) => {
2220
+ if (SKIP.test(rel) || !isProtected(rel, policy)) return;
2221
+ const s = snap(join7(dir, rel));
2222
+ const key2 = `${s.hash}:${s.mode}`;
2223
+ if (last.get(rel) === key2) return;
2224
+ last.set(rel, key2);
2225
+ const ev = { ts: (/* @__PURE__ */ new Date()).toISOString(), path: rel, kind: kind === "rename" ? "rename" : "change", ...s };
2226
+ try {
2227
+ appendFileSync3(log, JSON.stringify(ev) + "\n");
2228
+ } catch {
2229
+ }
2230
+ });
2231
+ }
2232
+ function runWatch(args) {
2233
+ let dir = process.cwd();
2234
+ let log = null;
2235
+ for (let i = 0; i < args.length; i++) {
2236
+ if (args[i] === "--dir" && args[i + 1]) dir = args[++i];
2237
+ else if (args[i] === "--log" && args[i + 1]) log = args[++i];
2238
+ }
2239
+ const policy = loadPolicy(dir);
2240
+ const out = log ?? defaultEventLog(dir);
2241
+ startWatcher(dir, out, policy);
2242
+ process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out}
2243
+ `);
2244
+ return -1;
2245
+ }
2246
+
2247
+ // src/detectors/fs-events.ts
2248
+ import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
2249
+ var RULE11 = "transient-protected-mutation";
2250
+ function readEvents(log, offset) {
2251
+ let size = 0;
2252
+ try {
2253
+ size = statSync3(log).size;
2254
+ } catch {
2255
+ return { events: [], newOffset: offset };
2256
+ }
2257
+ if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
2258
+ const raw = readFileSync8(log, "utf8").slice(offset);
2259
+ const events = [];
2260
+ for (const line of raw.split("\n")) {
2261
+ if (!line.trim()) continue;
2262
+ try {
2263
+ events.push(JSON.parse(line));
2264
+ } catch {
2265
+ }
2266
+ }
2267
+ return { events, newOffset: size };
2268
+ }
2269
+ function transientFindings(events, persistentPaths, policy, finalHash) {
2270
+ const byPath = /* @__PURE__ */ new Map();
2271
+ for (const e of events) {
2272
+ if (!isProtected(e.path, policy)) continue;
2273
+ const arr = byPath.get(e.path) ?? [];
2274
+ arr.push(e);
2275
+ byPath.set(e.path, arr);
2276
+ }
2277
+ const out = [];
2278
+ const strict = process.env.TAMPERWARD_TRANSIENT === "block";
2279
+ for (const [path, evs] of byPath) {
2280
+ if (persistentPaths.has(path)) continue;
2281
+ const final = finalHash(path);
2282
+ const hashes = new Set(evs.map((e) => e.hash).filter((h) => h != null));
2283
+ if (final != null) hashes.add(final);
2284
+ const contentChurn = hashes.size > 1;
2285
+ const modes = new Set(evs.map((e) => e.mode).filter((m) => m != null));
2286
+ const modeChurn = modes.size > 1;
2287
+ if (!contentChurn && !modeChurn) continue;
2288
+ 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 ")})`;
2289
+ const f = makeFinding(RULE11, policy, {
2290
+ file: path,
2291
+ message: `Transient protected mutation: ${what}, leaving no net diff.`,
2292
+ evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
2293
+ 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.",
2294
+ defaultSeverity: "warn"
2295
+ });
2296
+ if (strict && f.severity !== "block") {
2297
+ f.severity = "block";
2298
+ f.signoff.required = true;
2299
+ }
2300
+ out.push(f);
2301
+ }
2302
+ return out;
2303
+ }
2304
+
2028
2305
  // src/cli/hook.ts
2029
2306
  function readStdin() {
2030
2307
  try {
2031
- return readFileSync6(0, "utf8");
2308
+ return readFileSync9(0, "utf8");
2032
2309
  } catch {
2033
2310
  return "";
2034
2311
  }
@@ -2045,7 +2322,7 @@ function recordDenylog(blocks) {
2045
2322
  const log = process.env.TAMPERWARD_DENYLOG;
2046
2323
  if (!log) return;
2047
2324
  try {
2048
- appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
2325
+ appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
2049
2326
  } catch {
2050
2327
  }
2051
2328
  }
@@ -2077,30 +2354,141 @@ function verdict(blocks, kind) {
2077
2354
  } : { decision: "block", reason };
2078
2355
  return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
2079
2356
  }
2357
+ function effectDriftBlocks(cwd, sessionId, policy) {
2358
+ if (!sessionId || !isGitRepo(cwd)) return null;
2359
+ const expected = loadPtree(cwd, sessionId);
2360
+ const current = snapshotProtected(cwd, policy, expected ?? void 0);
2361
+ if (!expected) {
2362
+ savePtree(cwd, sessionId, current);
2363
+ return null;
2364
+ }
2365
+ const d = driftBetween(expected, current);
2366
+ if (d.changed.length === 0 && d.deleted.length === 0) {
2367
+ savePtree(cwd, sessionId, current);
2368
+ return null;
2369
+ }
2370
+ const base = turnBaseline(cwd, sessionId);
2371
+ const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
2372
+ const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
2373
+ const scoped = all.filter(
2374
+ (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
2375
+ );
2376
+ const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
2377
+ if (blocks.length > 0) return blocks;
2378
+ savePtree(cwd, sessionId, current);
2379
+ return null;
2380
+ }
2381
+ function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
2382
+ if (!sessionId) return;
2383
+ const tree = loadPtree(cwd, sessionId);
2384
+ if (!tree) return;
2385
+ let dirty = false;
2386
+ for (const c of changes) {
2387
+ if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
2388
+ const hash = createHash4("sha256").update(c.after).digest("hex").slice(0, 16);
2389
+ const prev = tree[c.path];
2390
+ tree[c.path] = {
2391
+ hash,
2392
+ mode: prev?.mode ?? 33188,
2393
+ size: Buffer.byteLength(c.after),
2394
+ mtimeMs: -1
2395
+ // unknown until written; forces a re-hash next snapshot, which is correct
2396
+ };
2397
+ dirty = true;
2398
+ }
2399
+ if (dirty) savePtree(cwd, sessionId, tree);
2400
+ }
2080
2401
  function preToolUseVerdict(input) {
2081
2402
  try {
2082
2403
  const cwd = input.cwd ?? process.cwd();
2083
2404
  turnBaseline(cwd, input.session_id);
2405
+ const policy = loadPolicy(cwd);
2406
+ const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
2407
+ if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
2084
2408
  const changes = changesFromClaudeHook(input, cwd);
2085
- const blocks = evaluate(changes, loadPolicy(cwd), void 0, "tool-call").filter((f) => f.severity === "block");
2409
+ const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
2410
+ if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
2086
2411
  return verdict(blocks, "PreToolUse");
2087
2412
  } catch (e) {
2088
2413
  return failClosed("PreToolUse", errText(e));
2089
2414
  }
2090
2415
  }
2416
+ function cursorPath(cwd, sessionId) {
2417
+ const p = ptreePath(cwd, sessionId);
2418
+ return p ? p.replace(/ptree-/, "fscursor-") : null;
2419
+ }
2420
+ function turnTransientBlocks(cwd, sessionId, policy, changes) {
2421
+ const none = { blocks: [], commit: () => {
2422
+ } };
2423
+ const log = defaultEventLog(cwd);
2424
+ const cp = cursorPath(cwd, sessionId);
2425
+ if (!existsSync6(log)) return none;
2426
+ let offset = 0;
2427
+ if (cp && existsSync6(cp)) {
2428
+ const n = Number(readFileSync9(cp, "utf8"));
2429
+ offset = Number.isFinite(n) ? n : 0;
2430
+ }
2431
+ const { events, newOffset } = readEvents(log, offset);
2432
+ if (events.length === 0) return { ...none, commit: () => {
2433
+ if (cp) try {
2434
+ writeFileSync4(cp, String(newOffset));
2435
+ } catch {
2436
+ }
2437
+ } };
2438
+ const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
2439
+ const finalHash = (path) => {
2440
+ try {
2441
+ if (!statSync4(`${cwd}/${path}`).isFile()) return null;
2442
+ return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
2443
+ } catch {
2444
+ return null;
2445
+ }
2446
+ };
2447
+ const findings = transientFindings(events, persistent, policy, finalHash);
2448
+ recordWarns(findings.filter((f) => f.severity !== "block"));
2449
+ return {
2450
+ blocks: findings.filter((f) => f.severity === "block"),
2451
+ commit: () => {
2452
+ if (cp) try {
2453
+ writeFileSync4(cp, String(newOffset));
2454
+ } catch {
2455
+ }
2456
+ }
2457
+ };
2458
+ }
2459
+ function recordWarns(warns) {
2460
+ const log = process.env.TAMPERWARD_DENYLOG;
2461
+ if (!log || warns.length === 0) return;
2462
+ try {
2463
+ appendFileSync4(log, warns.map((w) => `warn:${w.rule}:${w.file ?? ""}`).join("\n") + "\n");
2464
+ } catch {
2465
+ }
2466
+ }
2091
2467
  function stopVerdict(input) {
2092
2468
  if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
2093
2469
  const cwd = input.cwd ?? process.cwd();
2094
2470
  if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
2095
2471
  let blocks;
2472
+ let commitCursor = () => {
2473
+ };
2096
2474
  try {
2475
+ const policy = loadPolicy(cwd);
2097
2476
  const base = turnBaseline(cwd, input.session_id);
2098
2477
  const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
2099
- blocks = evaluate(changes, loadPolicy(cwd), void 0, "turn").filter((f) => f.severity === "block");
2478
+ blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
2479
+ const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
2480
+ blocks = blocks.concat(transient.blocks);
2481
+ commitCursor = transient.commit;
2482
+ if (blocks.length === 0 && input.session_id) {
2483
+ savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
2484
+ }
2100
2485
  } catch (e) {
2101
2486
  return failClosed("Stop", errText(e));
2102
2487
  }
2103
- if (blocks.length === 0) advanceTurnBaseline(cwd, input.session_id);
2488
+ if (blocks.length === 0) {
2489
+ advanceTurnBaseline(cwd, input.session_id);
2490
+ commitCursor();
2491
+ }
2104
2492
  return verdict(blocks, "Stop");
2105
2493
  }
2106
2494
  function emit(r) {
@@ -2169,8 +2557,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2169
2557
  }
2170
2558
 
2171
2559
  // src/cli/init.ts
2172
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3, chmodSync } from "node:fs";
2173
- import { dirname as dirname3, join as join6 } from "node:path";
2560
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2561
+ import { dirname as dirname3, join as join8 } from "node:path";
2174
2562
  var HOOK_CMD = "npx --yes tamperward hook claude";
2175
2563
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2176
2564
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
@@ -2230,24 +2618,24 @@ jobs:
2230
2618
  run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2231
2619
  `;
2232
2620
  function planPolicy(cwd) {
2233
- const path = join6(cwd, POLICY_FILE);
2234
- if (existsSync5(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
2621
+ const path = join8(cwd, POLICY_FILE);
2622
+ if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
2235
2623
  return {
2236
2624
  item: "policy",
2237
2625
  path: POLICY_FILE,
2238
2626
  status: "create",
2239
2627
  detail: "baseline policy with commented overrides",
2240
- apply: () => writeFileSync3(path, POLICY_CONTENT)
2628
+ apply: () => writeFileSync5(path, POLICY_CONTENT)
2241
2629
  };
2242
2630
  }
2243
2631
  function planClaudeHooks(cwd) {
2244
2632
  const rel = ".claude/settings.json";
2245
- const path = join6(cwd, rel);
2633
+ const path = join8(cwd, rel);
2246
2634
  let settings = {};
2247
- if (existsSync5(path)) {
2635
+ if (existsSync7(path)) {
2248
2636
  let parsed;
2249
2637
  try {
2250
- parsed = JSON.parse(readFileSync7(path, "utf8"));
2638
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
2251
2639
  } catch {
2252
2640
  return {
2253
2641
  item: "agent",
@@ -2269,7 +2657,7 @@ function planClaudeHooks(cwd) {
2269
2657
  return {
2270
2658
  item: "agent",
2271
2659
  path: rel,
2272
- status: existsSync5(path) ? "update" : "create",
2660
+ status: existsSync7(path) ? "update" : "create",
2273
2661
  detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
2274
2662
  apply: () => {
2275
2663
  if (needPre) {
@@ -2281,8 +2669,8 @@ function planClaudeHooks(cwd) {
2281
2669
  if (needStop) {
2282
2670
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
2283
2671
  }
2284
- mkdirSync3(dirname3(path), { recursive: true });
2285
- writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n");
2672
+ mkdirSync5(dirname3(path), { recursive: true });
2673
+ writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
2286
2674
  }
2287
2675
  };
2288
2676
  }
@@ -2290,12 +2678,12 @@ function planPreCommit(cwd) {
2290
2678
  const line = `${MARKER}
2291
2679
  ${PRECOMMIT_CMD}
2292
2680
  `;
2293
- const husky = join6(cwd, ".husky");
2294
- const gitDir2 = join6(cwd, ".git");
2295
- 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;
2681
+ const husky = join8(cwd, ".husky");
2682
+ const gitDir3 = join8(cwd, ".git");
2683
+ 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;
2296
2684
  if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
2297
- const path = join6(cwd, target.rel);
2298
- const existing = existsSync5(path) ? readFileSync7(path, "utf8") : null;
2685
+ const path = join8(cwd, target.rel);
2686
+ const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
2299
2687
  if (existing?.includes("tamperward check --staged")) {
2300
2688
  return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
2301
2689
  }
@@ -2305,26 +2693,26 @@ ${PRECOMMIT_CMD}
2305
2693
  status: existing === null ? "create" : "update",
2306
2694
  detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
2307
2695
  apply: () => {
2308
- mkdirSync3(dirname3(path), { recursive: true });
2696
+ mkdirSync5(dirname3(path), { recursive: true });
2309
2697
  const content = existing === null ? `#!/bin/sh
2310
2698
  ${line}` : existing.replace(/\n?$/, "\n") + line;
2311
- writeFileSync3(path, content);
2699
+ writeFileSync5(path, content);
2312
2700
  chmodSync(path, 493);
2313
2701
  }
2314
2702
  };
2315
2703
  }
2316
2704
  function planWorkflow(cwd) {
2317
2705
  const rel = ".github/workflows/tamperward.yml";
2318
- const path = join6(cwd, rel);
2319
- if (existsSync5(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2706
+ const path = join8(cwd, rel);
2707
+ if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2320
2708
  return {
2321
2709
  item: "ci",
2322
2710
  path: rel,
2323
2711
  status: "create",
2324
2712
  detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
2325
2713
  apply: () => {
2326
- mkdirSync3(dirname3(path), { recursive: true });
2327
- writeFileSync3(path, WORKFLOW_CONTENT);
2714
+ mkdirSync5(dirname3(path), { recursive: true });
2715
+ writeFileSync5(path, WORKFLOW_CONTENT);
2328
2716
  }
2329
2717
  };
2330
2718
  }
@@ -2359,6 +2747,171 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
2359
2747
  return 0;
2360
2748
  }
2361
2749
 
2750
+ // src/cli/verify.ts
2751
+ import { execFileSync as execFileSync4, spawnSync } from "node:child_process";
2752
+ import { chmodSync as chmodSync2, cpSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
2753
+ import { tmpdir as tmpdir2 } from "node:os";
2754
+ import { dirname as dirname4, join as join9 } from "node:path";
2755
+ var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
2756
+ function git2(args, cwd) {
2757
+ return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
2758
+ }
2759
+ function resolveBase(base, cwd) {
2760
+ const rev = git2(["rev-parse", "--verify", `${base}^{commit}`], cwd).trim();
2761
+ try {
2762
+ return git2(["merge-base", rev, "HEAD"], cwd).trim();
2763
+ } catch {
2764
+ return rev;
2765
+ }
2766
+ }
2767
+ function materialize(cwd, dest) {
2768
+ const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2769
+ for (const rel of listed) {
2770
+ const src = join9(cwd, rel);
2771
+ let st;
2772
+ try {
2773
+ st = statSync5(src);
2774
+ } catch {
2775
+ continue;
2776
+ }
2777
+ if (!st.isFile()) continue;
2778
+ const out = join9(dest, rel);
2779
+ mkdirSync6(dirname4(out), { recursive: true });
2780
+ cpSync(src, out);
2781
+ chmodSync2(out, st.mode);
2782
+ }
2783
+ const nm = join9(cwd, "node_modules");
2784
+ if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
2785
+ }
2786
+ function overlayPristine(cwd, base, dest, policy) {
2787
+ const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
2788
+ const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c));
2789
+ let restored = 0;
2790
+ const baseProtected = /* @__PURE__ */ new Set();
2791
+ for (const rel of atBase) {
2792
+ if (!isOverlay(rel)) continue;
2793
+ baseProtected.add(rel);
2794
+ const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
2795
+ const out = join9(dest, rel);
2796
+ mkdirSync6(dirname4(out), { recursive: true });
2797
+ writeFileSync6(out, content);
2798
+ restored++;
2799
+ }
2800
+ let kept = 0;
2801
+ const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2802
+ for (const rel of inCopy) if (isOverlay(rel) && !baseProtected.has(rel)) kept++;
2803
+ return [restored, kept];
2804
+ }
2805
+ function runSuite(dir, cmd, budgetSecs) {
2806
+ const t0 = Date.now();
2807
+ const r = spawnSync("sh", ["-c", cmd], {
2808
+ cwd: dir,
2809
+ stdio: "ignore",
2810
+ timeout: budgetSecs * 1e3,
2811
+ killSignal: "SIGKILL"
2812
+ });
2813
+ const secs = Math.round((Date.now() - t0) / 1e3);
2814
+ if (r.error && r.error.code === "ETIMEDOUT") return { exit: null, secs };
2815
+ if (r.status === null && r.signal) return { exit: null, secs };
2816
+ return { exit: r.status ?? 2, secs };
2817
+ }
2818
+ function runVerify(opts) {
2819
+ const cwd = opts.cwd ?? process.cwd();
2820
+ const out = (s) => void process.stdout.write(s + "\n");
2821
+ let policy;
2822
+ try {
2823
+ policy = loadPolicy(cwd);
2824
+ } catch (e) {
2825
+ out(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
2826
+ return 2;
2827
+ }
2828
+ const cmd = opts.cmd ?? policy.verify?.command;
2829
+ if (!cmd) {
2830
+ out("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
2831
+ return 2;
2832
+ }
2833
+ const budget = opts.budget ?? policy.verify?.budget ?? 300;
2834
+ let base;
2835
+ try {
2836
+ base = resolveBase(opts.base ?? "HEAD", cwd);
2837
+ } catch {
2838
+ out(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
2839
+ return 2;
2840
+ }
2841
+ const work = mkdtempSync2(join9(tmpdir2(), "tw-verify-"));
2842
+ const visDir = join9(work, "visible");
2843
+ const priDir = join9(work, "pristine");
2844
+ let restored = 0;
2845
+ let kept = 0;
2846
+ try {
2847
+ mkdirSync6(visDir);
2848
+ mkdirSync6(priDir);
2849
+ materialize(cwd, visDir);
2850
+ materialize(cwd, priDir);
2851
+ [restored, kept] = overlayPristine(cwd, base, priDir, policy);
2852
+ } catch (e) {
2853
+ if (!opts.keep) rmSync2(work, { recursive: true, force: true });
2854
+ out(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
2855
+ return 2;
2856
+ }
2857
+ const visible = runSuite(visDir, cmd, budget);
2858
+ const pristine = runSuite(priDir, cmd, budget);
2859
+ if (!opts.keep) rmSync2(work, { recursive: true, force: true });
2860
+ let verdict2;
2861
+ let code;
2862
+ if (visible.exit === null || pristine.exit === null) {
2863
+ verdict2 = "BUDGET_EXCEEDED";
2864
+ code = 2;
2865
+ } else if (visible.exit === 0 && pristine.exit === 0) {
2866
+ verdict2 = "VERIFIED";
2867
+ code = 0;
2868
+ } else if (visible.exit === 0) {
2869
+ verdict2 = "MASKED_FAILURE";
2870
+ code = 1;
2871
+ } else {
2872
+ verdict2 = "SUITE_RED";
2873
+ code = 1;
2874
+ }
2875
+ if (opts.json) {
2876
+ out(
2877
+ JSON.stringify({
2878
+ verdict: verdict2,
2879
+ base,
2880
+ command: cmd,
2881
+ budget_secs: budget,
2882
+ visible: { exit: visible.exit, secs: visible.secs },
2883
+ pristine: { exit: pristine.exit, secs: pristine.secs },
2884
+ protected_restored: restored,
2885
+ added_protected_kept: kept,
2886
+ ...opts.keep ? { workdir: work } : {}
2887
+ })
2888
+ );
2889
+ } else {
2890
+ const lines = {
2891
+ VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
2892
+ MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
2893
+ SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
2894
+ BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
2895
+ };
2896
+ out(`tamperward verify \u2014 ${lines[verdict2]}`);
2897
+ if (kept > 0) out(`(${kept} protected file(s) added since ${base.slice(0, 10)} were kept in the pristine run.)`);
2898
+ }
2899
+ return code;
2900
+ }
2901
+ function parseVerify(args) {
2902
+ const o = {};
2903
+ for (let i = 0; i < args.length; i++) {
2904
+ const a = args[i];
2905
+ if (a === "--base" && args[i + 1]) o.base = args[++i];
2906
+ else if (a === "--cmd" && args[i + 1]) o.cmd = args[++i];
2907
+ else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
2908
+ else if (a === "--json") o.json = true;
2909
+ else if (a === "--keep") o.keep = true;
2910
+ else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
2911
+ }
2912
+ return o;
2913
+ }
2914
+
2362
2915
  // src/cli/index.ts
2363
2916
  function parseAllow(args) {
2364
2917
  const o = {};
@@ -2431,6 +2984,14 @@ Formats:
2431
2984
  auto github when GITHUB_ACTIONS=true, otherwise text.
2432
2985
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
2433
2986
  tamperward sweep claude Stop sweep (re-scan the turn's working tree)
2987
+ tamperward watch [--dir D] [--log F] filesystem-event observer daemon: records
2988
+ protected-file events so the sweep can
2989
+ observe supported transient effects
2990
+ tamperward verify [--base R] [--cmd C] pristine-suite re-execution: run the suite
2991
+ [--budget S] [--json] [--keep] as-is AND with protected files restored
2992
+ from the trusted base; a visible-green /
2993
+ pristine-red result is a MASKED FAILURE
2994
+ (exit 1); cannot-verify fails closed (2)
2434
2995
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
2435
2996
  tamperward init [--dry-run] wire the policy file plus every
2436
2997
  enforcement point: Claude Code hooks,
@@ -2441,8 +3002,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
2441
3002
  emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
2442
3003
  `);
2443
3004
  }
2444
- function main(argv) {
2445
- const [cmd, ...rest] = argv;
3005
+ function main(argv2) {
3006
+ const [cmd, ...rest] = argv2;
2446
3007
  switch (cmd) {
2447
3008
  case "check":
2448
3009
  return runCheck(parseCheck(rest));
@@ -2454,6 +3015,8 @@ function main(argv) {
2454
3015
  return runAllow(parseAllow(rest));
2455
3016
  case "init":
2456
3017
  return runInit(parseInit(rest));
3018
+ case "verify":
3019
+ return runVerify(parseVerify(rest));
2457
3020
  case void 0:
2458
3021
  case "-h":
2459
3022
  case "--help":
@@ -2466,4 +3029,9 @@ function main(argv) {
2466
3029
  return 2;
2467
3030
  }
2468
3031
  }
2469
- process.exit(main(process.argv.slice(2)));
3032
+ var argv = process.argv.slice(2);
3033
+ if (argv[0] === "watch") {
3034
+ runWatch(argv.slice(1));
3035
+ } else {
3036
+ process.exit(main(argv));
3037
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",