tamperward 2.13.2 → 2.13.3

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
@@ -149,7 +149,14 @@ weakening operations, detect protected-tree drift, and sweep the turn's net
149
149
  changes. The `Stop` hook runs that sweep — it does **not** invoke `verify`.
150
150
  `tamperward watch` can additionally record transient mutations, but it is an
151
151
  optional observer that `init` neither starts nor supervises, and it is not an
152
- enforcement boundary.
152
+ enforcement boundary. Since **2.13.3** the observer writes a health sidecar next to
153
+ its JSONL event log, recording backend, PID/start time, watched-directory count,
154
+ successful appends, dropped events and errors. `tamperward doctor` reports that
155
+ channel as **healthy**, **degraded**, or **unavailable**, and Stop writes the same
156
+ non-authoritative state into the audit log when `TAMPERWARD_DENYLOG` is enabled.
157
+ This distinguishes “healthy observer, zero events” from “observer telemetry was
158
+ unavailable” without turning absence of watcher telemetry into an enforcement pass
159
+ or failure.
153
160
 
154
161
  After the runtime exits its exit code is treated as untrusted, and the envelope
155
162
  checks that post-agent `HEAD` still descends from the entry commit; the committed
package/dist/cli/index.js CHANGED
@@ -6828,7 +6828,7 @@ function runCheck(opts) {
6828
6828
  }
6829
6829
 
6830
6830
  // src/cli/hook.ts
6831
- import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync8 } from "node:fs";
6831
+ import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "node:fs";
6832
6832
  import { createHash as createHash5 } from "node:crypto";
6833
6833
 
6834
6834
  // src/adapters/claude/deny.ts
@@ -7025,7 +7025,7 @@ function dropTurnTree(cwd, sessionId) {
7025
7025
  }
7026
7026
 
7027
7027
  // src/cli/watch.ts
7028
- import { appendFileSync as appendFileSync3, lstatSync as lstatSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, watch } from "node:fs";
7028
+ import { appendFileSync as appendFileSync3, lstatSync as lstatSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync9, readdirSync as readdirSync2, watch, writeFileSync as writeFileSync4 } from "node:fs";
7029
7029
  import { join as join11 } from "node:path";
7030
7030
  import { createHash as createHash4 } from "node:crypto";
7031
7031
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
@@ -7040,17 +7040,51 @@ function defaultEventLog(cwd) {
7040
7040
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
7041
7041
  return join11(gitDir(cwd) ?? join11(cwd, ".git"), "tamperward", "fsevents.jsonl");
7042
7042
  }
7043
- function watchTree(dir, cb) {
7043
+ function watcherHealthPath(log) {
7044
+ return `${log}.health.json`;
7045
+ }
7046
+ function readWatcherHealth(log) {
7047
+ try {
7048
+ const value = JSON.parse(readFileSync9(watcherHealthPath(log), "utf8"));
7049
+ if (value.version !== 1 || typeof value.pid !== "number" || typeof value.started_at !== "string" || !["healthy", "degraded", "stopped"].includes(String(value.state))) return null;
7050
+ return value;
7051
+ } catch {
7052
+ return null;
7053
+ }
7054
+ }
7055
+ function pidAlive(pid) {
7056
+ if (!Number.isInteger(pid) || pid <= 0) return false;
7057
+ try {
7058
+ process.kill(pid, 0);
7059
+ return true;
7060
+ } catch (e) {
7061
+ return e.code === "EPERM";
7062
+ }
7063
+ }
7064
+ function watcherTelemetry(log) {
7065
+ const health = readWatcherHealth(log);
7066
+ if (!health) return { state: "unavailable", health: null, reason: "no health record" };
7067
+ if (health.state === "stopped")
7068
+ return { state: "unavailable", health, reason: "observer stopped" };
7069
+ if (!pidAlive(health.pid))
7070
+ return { state: "unavailable", health, reason: `observer pid ${health.pid} is not running` };
7071
+ if (health.state === "degraded")
7072
+ return { state: "degraded", health, reason: health.last_error ?? "observer reported an error" };
7073
+ return { state: "healthy", health };
7074
+ }
7075
+ function watchTree(dir, cb, onState, onError) {
7044
7076
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
7045
7077
  try {
7046
7078
  const w = watch(dir, { recursive: true }, (kind, fname) => {
7047
7079
  if (fname) cb(kind, String(fname));
7048
7080
  });
7081
+ onState("recursive", 1);
7049
7082
  return { close: () => w.close() };
7050
7083
  } catch {
7051
7084
  }
7052
7085
  }
7053
7086
  const watchers = /* @__PURE__ */ new Map();
7087
+ onState("fallback", 0);
7054
7088
  const addDir = (rel) => {
7055
7089
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
7056
7090
  let w;
@@ -7064,17 +7098,24 @@ function watchTree(dir, cb) {
7064
7098
  } catch {
7065
7099
  }
7066
7100
  });
7067
- } catch {
7101
+ } catch (e) {
7102
+ onError(
7103
+ `watch directory ${rel || "."}: ${e instanceof Error ? e.message : String(e)}`
7104
+ );
7068
7105
  return;
7069
7106
  }
7070
7107
  watchers.set(rel, w);
7108
+ onState("fallback", watchers.size);
7071
7109
  };
7072
7110
  const walk2 = (rel) => {
7073
7111
  addDir(rel);
7074
7112
  let names = [];
7075
7113
  try {
7076
7114
  names = readdirSync2(rel ? join11(dir, rel) : dir);
7077
- } catch {
7115
+ } catch (e) {
7116
+ onError(
7117
+ `enumerate directory ${rel || "."}: ${e instanceof Error ? e.message : String(e)}`
7118
+ );
7078
7119
  return;
7079
7120
  }
7080
7121
  for (const name of names) {
@@ -7096,19 +7137,91 @@ function startWatcher(dir, log, policy) {
7096
7137
  mkdirSync4(join11(log, ".."), { recursive: true });
7097
7138
  } catch {
7098
7139
  }
7099
- const last = /* @__PURE__ */ new Map();
7100
- return watchTree(dir, (kind, rel) => {
7101
- if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
7102
- const s = snap(join11(dir, rel));
7103
- const key2 = `${s.hash}:${s.mode}`;
7104
- if (last.get(rel) === key2) return;
7105
- last.set(rel, key2);
7106
- const ev = { ts: (/* @__PURE__ */ new Date()).toISOString(), path: rel, kind: kind === "rename" ? "rename" : "change", ...s };
7140
+ const healthPath = watcherHealthPath(log);
7141
+ const health = {
7142
+ version: 1,
7143
+ state: "healthy",
7144
+ backend: "initializing",
7145
+ pid: process.pid,
7146
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
7147
+ stopped_at: null,
7148
+ watched_dirs: 0,
7149
+ last_append_at: null,
7150
+ event_count: 0,
7151
+ dropped_events: 0,
7152
+ error_count: 0,
7153
+ last_error: null,
7154
+ log
7155
+ };
7156
+ let healthWriteWarningEmitted = false;
7157
+ const persistHealth = () => {
7107
7158
  try {
7108
- appendFileSync3(log, JSON.stringify(ev) + "\n");
7109
- } catch {
7159
+ writeFileSync4(healthPath, JSON.stringify(health) + "\n", { mode: 384 });
7160
+ healthWriteWarningEmitted = false;
7161
+ } catch (e) {
7162
+ if (!healthWriteWarningEmitted) {
7163
+ process.stderr.write(
7164
+ `tamperward watch: WARNING: observer health cannot be recorded at ${healthPath} (${e instanceof Error ? e.message : String(e)}); telemetry availability is unknown.
7165
+ `
7166
+ );
7167
+ healthWriteWarningEmitted = true;
7168
+ }
7110
7169
  }
7111
- });
7170
+ };
7171
+ const degrade = (detail, droppedEvent = false) => {
7172
+ health.state = "degraded";
7173
+ health.error_count++;
7174
+ if (droppedEvent) health.dropped_events++;
7175
+ health.last_error = detail;
7176
+ persistHealth();
7177
+ process.stderr.write(
7178
+ `tamperward watch: WARNING: observer degraded \u2014 ${detail}. Transient telemetry may be incomplete.
7179
+ `
7180
+ );
7181
+ };
7182
+ persistHealth();
7183
+ const last = /* @__PURE__ */ new Map();
7184
+ const tree = watchTree(
7185
+ dir,
7186
+ (kind, rel) => {
7187
+ if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
7188
+ const s = snap(join11(dir, rel));
7189
+ const key2 = `${s.hash}:${s.mode}`;
7190
+ if (last.get(rel) === key2) return;
7191
+ last.set(rel, key2);
7192
+ const ev = {
7193
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
7194
+ path: rel,
7195
+ kind: kind === "rename" ? "rename" : "change",
7196
+ ...s
7197
+ };
7198
+ try {
7199
+ appendFileSync3(log, JSON.stringify(ev) + "\n");
7200
+ health.event_count++;
7201
+ health.last_append_at = (/* @__PURE__ */ new Date()).toISOString();
7202
+ persistHealth();
7203
+ } catch (e) {
7204
+ degrade(
7205
+ `append event to ${log}: ${e instanceof Error ? e.message : String(e)}`,
7206
+ true
7207
+ );
7208
+ }
7209
+ },
7210
+ (backend, watchedDirs) => {
7211
+ health.backend = backend;
7212
+ health.watched_dirs = watchedDirs;
7213
+ persistHealth();
7214
+ },
7215
+ (detail) => degrade(detail)
7216
+ );
7217
+ return {
7218
+ close: () => {
7219
+ tree.close();
7220
+ health.state = "stopped";
7221
+ health.stopped_at = (/* @__PURE__ */ new Date()).toISOString();
7222
+ persistHealth();
7223
+ }
7224
+ };
7112
7225
  }
7113
7226
  function runWatch(args) {
7114
7227
  let dir = process.cwd();
@@ -7120,7 +7233,7 @@ function runWatch(args) {
7120
7233
  const policy = loadPolicy(dir);
7121
7234
  const out2 = log ?? defaultEventLog(dir);
7122
7235
  startWatcher(dir, out2, policy);
7123
- process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out2}
7236
+ process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out2} (health: ${watcherHealthPath(out2)})
7124
7237
  `);
7125
7238
  return -1;
7126
7239
  }
@@ -7441,6 +7554,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7441
7554
  const none = { blocks: [], commit: () => {
7442
7555
  } };
7443
7556
  const log = defaultEventLog(cwd);
7557
+ const telemetry = watcherTelemetry(log);
7558
+ if (telemetry.state !== "healthy") {
7559
+ recordObserverHealth(telemetry.state, telemetry.reason);
7560
+ }
7444
7561
  const cp = cursorPath(cwd, sessionId);
7445
7562
  if (!existsSync8(log)) return none;
7446
7563
  let offset = 0;
@@ -7451,7 +7568,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7451
7568
  const { events, newOffset } = readEvents(log, offset);
7452
7569
  if (events.length === 0) return { ...none, commit: () => {
7453
7570
  if (cp) try {
7454
- writeFileSync4(cp, String(newOffset));
7571
+ writeFileSync5(cp, String(newOffset));
7455
7572
  } catch {
7456
7573
  }
7457
7574
  } };
@@ -7466,7 +7583,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7466
7583
  blocks: findings.filter((f) => f.severity === "block"),
7467
7584
  commit: () => {
7468
7585
  if (cp) try {
7469
- writeFileSync4(cp, String(newOffset));
7586
+ writeFileSync5(cp, String(newOffset));
7470
7587
  } catch {
7471
7588
  }
7472
7589
  }
@@ -7480,6 +7597,19 @@ function recordWarns(warns) {
7480
7597
  } catch {
7481
7598
  }
7482
7599
  }
7600
+ function recordObserverHealth(state, reason) {
7601
+ const log = process.env.TAMPERWARD_DENYLOG;
7602
+ if (!log) return;
7603
+ const detail = (reason ?? "").replace(/[\r\n]+/g, " ").trim();
7604
+ try {
7605
+ appendFileSync4(
7606
+ log,
7607
+ `warn:transient-observer:${state}${detail ? ":" + detail : ""}
7608
+ `
7609
+ );
7610
+ } catch {
7611
+ }
7612
+ }
7483
7613
  function stopVerdict(input) {
7484
7614
  if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
7485
7615
  const cwd = input.cwd ?? process.cwd();
@@ -7598,7 +7728,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
7598
7728
  // src/cli/init.ts
7599
7729
  import { execFileSync as execFileSync6 } from "node:child_process";
7600
7730
  import { createHash as createHash6 } from "node:crypto";
7601
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync2 } from "node:fs";
7731
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync6, chmodSync, statSync as statSync2 } from "node:fs";
7602
7732
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
7603
7733
 
7604
7734
  // src/verifier-limits.ts
@@ -7832,7 +7962,7 @@ function planPolicy(cwd) {
7832
7962
  path: POLICY_FILE,
7833
7963
  status: "create",
7834
7964
  detail: "baseline policy with commented overrides",
7835
- apply: () => writeFileSync5(path, POLICY_CONTENT)
7965
+ apply: () => writeFileSync6(path, POLICY_CONTENT)
7836
7966
  };
7837
7967
  }
7838
7968
  function planClaudeHooks(cwd) {
@@ -7904,7 +8034,7 @@ function planClaudeHooks(cwd) {
7904
8034
  }
7905
8035
  if (needDisableFalse) settings.disableAllHooks = false;
7906
8036
  mkdirSync5(dirname4(path), { recursive: true });
7907
- writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
8037
+ writeFileSync6(path, JSON.stringify(settings, null, 2) + "\n");
7908
8038
  }
7909
8039
  };
7910
8040
  }
@@ -7997,7 +8127,7 @@ function planPreCommit(cwd) {
7997
8127
  detail: `create via ${note}`,
7998
8128
  apply: () => {
7999
8129
  mkdirSync5(dirname4(path), { recursive: true });
8000
- writeFileSync5(path, `#!/bin/sh
8130
+ writeFileSync6(path, `#!/bin/sh
8001
8131
  ${block.join("\n")}
8002
8132
  `);
8003
8133
  chmodSync(path, 493);
@@ -8012,7 +8142,7 @@ ${block.join("\n")}
8012
8142
  const dead = unconditionalExitAt(lines);
8013
8143
  const at = lines.findIndex((l) => PRECOMMIT_RE.test(l));
8014
8144
  const write = (out2) => {
8015
- writeFileSync5(path, out2.join("\n"));
8145
+ writeFileSync6(path, out2.join("\n"));
8016
8146
  chmodSync(path, 493);
8017
8147
  };
8018
8148
  if (at !== -1 && (dead === -1 || at < dead)) {
@@ -8107,7 +8237,7 @@ ${CODEOWNERS_MARK}
8107
8237
  } : {},
8108
8238
  apply: () => {
8109
8239
  mkdirSync5(dirname4(path), { recursive: true });
8110
- writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
8240
+ writeFileSync6(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
8111
8241
  }
8112
8242
  };
8113
8243
  }
@@ -8116,7 +8246,7 @@ function planWorkflow(cwd, force) {
8116
8246
  const path = join12(cwd, rel);
8117
8247
  const write = () => {
8118
8248
  mkdirSync5(dirname4(path), { recursive: true });
8119
- writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
8249
+ writeFileSync6(path, workflowFile(WORKFLOW_CONTENT));
8120
8250
  };
8121
8251
  if (!existsSync9(path)) {
8122
8252
  return {
@@ -8529,13 +8659,30 @@ function runDoctor(opts = {}) {
8529
8659
  "tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes.\n"
8530
8660
  );
8531
8661
  }
8662
+ const observer = watcherTelemetry(defaultEventLog(cwd));
8663
+ if (observer.state === "healthy" && observer.health) {
8664
+ process.stdout.write(
8665
+ `tamperward doctor: transient observer: healthy \u2014 ${observer.health.backend}, ${observer.health.watched_dirs} watched dir(s), ${observer.health.event_count} event(s); advisory telemetry only, not verification authority.
8666
+ `
8667
+ );
8668
+ } else if (observer.state === "degraded" && observer.health) {
8669
+ process.stdout.write(
8670
+ `tamperward doctor: transient observer: degraded \u2014 ${observer.health.error_count} error(s), ${observer.health.dropped_events} dropped event(s); ${observer.reason ?? "telemetry may be incomplete"}. Advisory only; zero events are not evidence of no transient activity.
8671
+ `
8672
+ );
8673
+ } else {
8674
+ process.stdout.write(
8675
+ `tamperward doctor: transient observer: unavailable${observer.reason ? " \u2014 " + observer.reason : ""}. Observer telemetry is optional/advisory; zero events are not evidence of no transient activity.
8676
+ `
8677
+ );
8678
+ }
8532
8679
  return 0;
8533
8680
  }
8534
8681
 
8535
8682
  // src/cli/verify.ts
8536
8683
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
8537
8684
  import { createHash as createHash10 } from "node:crypto";
8538
- import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync5, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
8685
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync5, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync7 } from "node:fs";
8539
8686
  import { tmpdir as tmpdir2 } from "node:os";
8540
8687
  import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
8541
8688
 
@@ -9655,7 +9802,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
9655
9802
  safeSymlink(target, out2, dest, `the base's ${e.path}`);
9656
9803
  restoredLinks.push({ path: e.path, out: out2, target });
9657
9804
  } else {
9658
- writeFileSync6(out2, content);
9805
+ writeFileSync7(out2, content);
9659
9806
  chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
9660
9807
  }
9661
9808
  restored.push(e.path);
@@ -9711,7 +9858,7 @@ function suiteEnv(scratch) {
9711
9858
  }
9712
9859
  const userRc = join16(scratch, "npmrc-user");
9713
9860
  const globalRc = join16(scratch, "npmrc-global");
9714
- for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
9861
+ for (const f of [userRc, globalRc]) writeFileSync7(f, "", { mode: 292 });
9715
9862
  env.npm_config_userconfig = userRc;
9716
9863
  env.npm_config_globalconfig = globalRc;
9717
9864
  env.npm_config_node_options = " ";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.13.2",
3
+ "version": "2.13.3",
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",