tamperward 1.7.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.
Files changed (2) hide show
  1. package/dist/cli/index.js +411 -34
  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).
@@ -1721,10 +1726,10 @@ function locationCell(f, env) {
1721
1726
  const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1722
1727
  const server = env.GITHUB_SERVER_URL;
1723
1728
  const repo = env.GITHUB_REPOSITORY;
1724
- const sha = env.GITHUB_SHA;
1725
- if (!server || !repo || !sha) return `\`${cell(label)}\``;
1729
+ const sha2 = env.GITHUB_SHA;
1730
+ if (!server || !repo || !sha2) return `\`${cell(label)}\``;
1726
1731
  const frag = f.line ? `#L${f.line}` : "";
1727
- return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
1732
+ return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
1728
1733
  }
1729
1734
  function renderSummary(input, env = process.env) {
1730
1735
  const findings = orderFindings(input.findings);
@@ -1870,7 +1875,8 @@ function runCheck(opts) {
1870
1875
  }
1871
1876
 
1872
1877
  // src/cli/hook.ts
1873
- 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";
1874
1880
 
1875
1881
  // src/adapters/claude/changes.ts
1876
1882
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -1895,6 +1901,7 @@ function relForDisplay(path, cwd) {
1895
1901
  }
1896
1902
  function applyEdit(content, oldStr, newStr) {
1897
1903
  if (content === null) return newStr;
1904
+ if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
1898
1905
  return content.replace(oldStr, () => newStr);
1899
1906
  }
1900
1907
  function synthFileChange(displayPath, before, after) {
@@ -2025,10 +2032,261 @@ function advanceTurnBaseline(cwd, sessionId) {
2025
2032
  }
2026
2033
  }
2027
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
+
2028
2286
  // src/cli/hook.ts
2029
2287
  function readStdin() {
2030
2288
  try {
2031
- return readFileSync6(0, "utf8");
2289
+ return readFileSync9(0, "utf8");
2032
2290
  } catch {
2033
2291
  return "";
2034
2292
  }
@@ -2045,7 +2303,7 @@ function recordDenylog(blocks) {
2045
2303
  const log = process.env.TAMPERWARD_DENYLOG;
2046
2304
  if (!log) return;
2047
2305
  try {
2048
- appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
2306
+ appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
2049
2307
  } catch {
2050
2308
  }
2051
2309
  }
@@ -2077,30 +2335,141 @@ function verdict(blocks, kind) {
2077
2335
  } : { decision: "block", reason };
2078
2336
  return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
2079
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
+ }
2080
2382
  function preToolUseVerdict(input) {
2081
2383
  try {
2082
2384
  const cwd = input.cwd ?? process.cwd();
2083
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");
2084
2389
  const changes = changesFromClaudeHook(input, cwd);
2085
- 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);
2086
2392
  return verdict(blocks, "PreToolUse");
2087
2393
  } catch (e) {
2088
2394
  return failClosed("PreToolUse", errText(e));
2089
2395
  }
2090
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
+ }
2091
2448
  function stopVerdict(input) {
2092
2449
  if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
2093
2450
  const cwd = input.cwd ?? process.cwd();
2094
2451
  if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
2095
2452
  let blocks;
2453
+ let commitCursor = () => {
2454
+ };
2096
2455
  try {
2456
+ const policy = loadPolicy(cwd);
2097
2457
  const base = turnBaseline(cwd, input.session_id);
2098
2458
  const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
2099
- 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
+ }
2100
2466
  } catch (e) {
2101
2467
  return failClosed("Stop", errText(e));
2102
2468
  }
2103
- if (blocks.length === 0) advanceTurnBaseline(cwd, input.session_id);
2469
+ if (blocks.length === 0) {
2470
+ advanceTurnBaseline(cwd, input.session_id);
2471
+ commitCursor();
2472
+ }
2104
2473
  return verdict(blocks, "Stop");
2105
2474
  }
2106
2475
  function emit(r) {
@@ -2169,8 +2538,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2169
2538
  }
2170
2539
 
2171
2540
  // 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";
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";
2174
2543
  var HOOK_CMD = "npx --yes tamperward hook claude";
2175
2544
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2176
2545
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
@@ -2230,24 +2599,24 @@ jobs:
2230
2599
  run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
2231
2600
  `;
2232
2601
  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" };
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" };
2235
2604
  return {
2236
2605
  item: "policy",
2237
2606
  path: POLICY_FILE,
2238
2607
  status: "create",
2239
2608
  detail: "baseline policy with commented overrides",
2240
- apply: () => writeFileSync3(path, POLICY_CONTENT)
2609
+ apply: () => writeFileSync5(path, POLICY_CONTENT)
2241
2610
  };
2242
2611
  }
2243
2612
  function planClaudeHooks(cwd) {
2244
2613
  const rel = ".claude/settings.json";
2245
- const path = join6(cwd, rel);
2614
+ const path = join8(cwd, rel);
2246
2615
  let settings = {};
2247
- if (existsSync5(path)) {
2616
+ if (existsSync7(path)) {
2248
2617
  let parsed;
2249
2618
  try {
2250
- parsed = JSON.parse(readFileSync7(path, "utf8"));
2619
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
2251
2620
  } catch {
2252
2621
  return {
2253
2622
  item: "agent",
@@ -2269,7 +2638,7 @@ function planClaudeHooks(cwd) {
2269
2638
  return {
2270
2639
  item: "agent",
2271
2640
  path: rel,
2272
- status: existsSync5(path) ? "update" : "create",
2641
+ status: existsSync7(path) ? "update" : "create",
2273
2642
  detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
2274
2643
  apply: () => {
2275
2644
  if (needPre) {
@@ -2281,8 +2650,8 @@ function planClaudeHooks(cwd) {
2281
2650
  if (needStop) {
2282
2651
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
2283
2652
  }
2284
- mkdirSync3(dirname3(path), { recursive: true });
2285
- writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n");
2653
+ mkdirSync5(dirname3(path), { recursive: true });
2654
+ writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
2286
2655
  }
2287
2656
  };
2288
2657
  }
@@ -2290,12 +2659,12 @@ function planPreCommit(cwd) {
2290
2659
  const line = `${MARKER}
2291
2660
  ${PRECOMMIT_CMD}
2292
2661
  `;
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;
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;
2296
2665
  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;
2666
+ const path = join8(cwd, target.rel);
2667
+ const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
2299
2668
  if (existing?.includes("tamperward check --staged")) {
2300
2669
  return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
2301
2670
  }
@@ -2305,26 +2674,26 @@ ${PRECOMMIT_CMD}
2305
2674
  status: existing === null ? "create" : "update",
2306
2675
  detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
2307
2676
  apply: () => {
2308
- mkdirSync3(dirname3(path), { recursive: true });
2677
+ mkdirSync5(dirname3(path), { recursive: true });
2309
2678
  const content = existing === null ? `#!/bin/sh
2310
2679
  ${line}` : existing.replace(/\n?$/, "\n") + line;
2311
- writeFileSync3(path, content);
2680
+ writeFileSync5(path, content);
2312
2681
  chmodSync(path, 493);
2313
2682
  }
2314
2683
  };
2315
2684
  }
2316
2685
  function planWorkflow(cwd) {
2317
2686
  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" };
2687
+ const path = join8(cwd, rel);
2688
+ if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2320
2689
  return {
2321
2690
  item: "ci",
2322
2691
  path: rel,
2323
2692
  status: "create",
2324
2693
  detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
2325
2694
  apply: () => {
2326
- mkdirSync3(dirname3(path), { recursive: true });
2327
- writeFileSync3(path, WORKFLOW_CONTENT);
2695
+ mkdirSync5(dirname3(path), { recursive: true });
2696
+ writeFileSync5(path, WORKFLOW_CONTENT);
2328
2697
  }
2329
2698
  };
2330
2699
  }
@@ -2431,6 +2800,9 @@ Formats:
2431
2800
  auto github when GITHUB_ACTIONS=true, otherwise text.
2432
2801
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
2433
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
2434
2806
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
2435
2807
  tamperward init [--dry-run] wire the policy file plus every
2436
2808
  enforcement point: Claude Code hooks,
@@ -2441,8 +2813,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
2441
2813
  emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
2442
2814
  `);
2443
2815
  }
2444
- function main(argv) {
2445
- const [cmd, ...rest] = argv;
2816
+ function main(argv2) {
2817
+ const [cmd, ...rest] = argv2;
2446
2818
  switch (cmd) {
2447
2819
  case "check":
2448
2820
  return runCheck(parseCheck(rest));
@@ -2466,4 +2838,9 @@ function main(argv) {
2466
2838
  return 2;
2467
2839
  }
2468
2840
  }
2469
- 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.7.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",