tamperward 2.15.0 → 2.15.2

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
@@ -167,6 +167,19 @@ observer down deterministically. Observer health remains advisory; transient fin
167
167
  remain warnings by default and affect the envelope only when the operator explicitly
168
168
  sets `TAMPERWARD_TRANSIENT=block`.
169
169
 
170
+ Since **2.15.2**, Stop-sweep event consumption uses the saved cursor as a real byte
171
+ offset: it performs positioned reads of only new JSONL bytes instead of decoding the
172
+ whole historical log on every turn. Each physical read is capped at **4 MiB**, and one
173
+ authority decision drains at most **16 MiB** in bounded chunks. Only complete
174
+ newline-terminated records advance the cursor, so a torn final watcher write is replayed
175
+ after completion instead of being lost. Stop commits the cursor only after the entire
176
+ bounded telemetry tail has been parsed and classified; malformed records, an oversized
177
+ single record, a torn tail, or telemetry beyond the 16 MiB aggregate ceiling blocks Stop
178
+ and retains the previous cursor rather than certifying unjudged bytes. Supervised
179
+ `run --observe-transients` keeps observer evidence advisory by default, but when
180
+ `TAMPERWARD_TRANSIENT=block` is explicitly enabled, any unclassified observer tail
181
+ also fails the envelope closed.
182
+
170
183
  After the runtime exits its exit code is treated as untrusted, and the envelope
171
184
  checks that post-agent `HEAD` still descends from the entry commit; the committed
172
185
  changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
@@ -229,10 +242,19 @@ Since **2.15.0**, `tamperward doctor` is also the one-shot installation/posture
229
242
  report promised by `init`'s security model. It reuses `init`'s canonical wiring
230
243
  planner rather than maintaining a second definition of “installed correctly”, and
231
244
  reports named `OK`, `WARN`, or `BROKEN` checks for policy/schema, Claude hooks,
232
- pre-commit, generated CI wiring, CODEOWNERS, workflow permissions, binary/pin
245
+ pre-commit, CI authority wiring, CODEOWNERS, workflow permissions, binary/pin
233
246
  alignment, verifier trust mode + declared inputs, platform residuals, CI verifier
234
247
  outer time, observer health, and (with `--github`) repository authority. Use
235
248
  `--json` for one machine-readable document with `authoritative: true|false`.
249
+
250
+ **2.15.1 closes an authority-reporting gap in that surface.** The permission check now
251
+ includes job-level overrides, not only workflow-root permissions; a policy schema newer
252
+ than the running binary is BROKEN rather than certifiable; and the exact workflow(s)
253
+ selected by `--workflow` or discovered as verifier authorities are also the workflow(s)
254
+ used for CI-wiring and token-permission posture. A custom verifier workflow therefore
255
+ does not get judged against an unrelated/missing generated file, and a second verifier
256
+ workflow with a write-scoped job cannot hide behind a safe canonical workflow.
257
+
236
258
  Local early-layer gaps remain posture findings rather than silently changing the
237
259
  existing generated-CI exit contract; hard CI/GitHub validation failures still exit 2.
238
260
 
package/dist/cli/index.js CHANGED
@@ -6829,7 +6829,7 @@ function runCheck(opts) {
6829
6829
  }
6830
6830
 
6831
6831
  // src/cli/hook.ts
6832
- import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "node:fs";
6832
+ import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "node:fs";
6833
6833
  import { createHash as createHash5 } from "node:crypto";
6834
6834
 
6835
6835
  // src/adapters/claude/deny.ts
@@ -7253,26 +7253,200 @@ function runWatch(args) {
7253
7253
  }
7254
7254
 
7255
7255
  // src/detectors/fs-events.ts
7256
- import { readFileSync as readFileSync10, statSync } from "node:fs";
7256
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync } from "node:fs";
7257
7257
  var RULE11 = "transient-protected-mutation";
7258
- function readEvents(log, offset) {
7258
+ var MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
7259
+ var MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
7260
+ var DEFAULT_EVENT_READ_IO = {
7261
+ read: (fd, buffer, bufferOffset, length, position) => readSync2(fd, buffer, bufferOffset, length, position)
7262
+ };
7263
+ function readEvents(log, offset, maxBytes = MAX_EVENT_READ_BYTES, io = DEFAULT_EVENT_READ_IO) {
7259
7264
  let size = 0;
7260
7265
  try {
7261
7266
  size = statSync(log).size;
7262
7267
  } catch {
7263
- return { events: [], newOffset: offset };
7268
+ return {
7269
+ events: [],
7270
+ newOffset: Math.max(0, Number.isFinite(offset) ? Math.floor(offset) : 0),
7271
+ bytesRead: 0,
7272
+ limitReached: false,
7273
+ malformedLines: 0,
7274
+ incompleteTail: false
7275
+ };
7276
+ }
7277
+ let start = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
7278
+ if (start > size) start = 0;
7279
+ if (size <= start) {
7280
+ return {
7281
+ events: [],
7282
+ newOffset: start,
7283
+ bytesRead: 0,
7284
+ limitReached: false,
7285
+ malformedLines: 0,
7286
+ incompleteTail: false
7287
+ };
7288
+ }
7289
+ const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.max(1, Math.floor(maxBytes)) : MAX_EVENT_READ_BYTES;
7290
+ const available = size - start;
7291
+ const requested = Math.min(available, cap);
7292
+ const buffer = Buffer.allocUnsafe(requested);
7293
+ const fd = openSync2(log, "r");
7294
+ let bytesRead = 0;
7295
+ try {
7296
+ bytesRead = io.read(fd, buffer, 0, requested, start);
7297
+ } finally {
7298
+ closeSync2(fd);
7299
+ }
7300
+ if (bytesRead <= 0) {
7301
+ return {
7302
+ events: [],
7303
+ newOffset: start,
7304
+ bytesRead: 0,
7305
+ limitReached: available > 0,
7306
+ malformedLines: 0,
7307
+ incompleteTail: available > 0
7308
+ };
7309
+ }
7310
+ const chunk = buffer.subarray(0, bytesRead);
7311
+ const lastNewline = chunk.lastIndexOf(10);
7312
+ const limitReached = start + bytesRead < size;
7313
+ if (lastNewline < 0) {
7314
+ return {
7315
+ events: [],
7316
+ newOffset: start,
7317
+ bytesRead,
7318
+ limitReached,
7319
+ malformedLines: 0,
7320
+ incompleteTail: true
7321
+ };
7264
7322
  }
7265
- if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
7266
- const raw = readFileSync10(log, "utf8").slice(offset);
7267
7323
  const events = [];
7268
- for (const line of raw.split("\n")) {
7324
+ let malformedLines = 0;
7325
+ const complete = chunk.subarray(0, lastNewline + 1).toString("utf8");
7326
+ for (const line of complete.split("\n")) {
7269
7327
  if (!line.trim()) continue;
7270
7328
  try {
7271
7329
  events.push(JSON.parse(line));
7272
7330
  } catch {
7331
+ malformedLines++;
7332
+ }
7333
+ }
7334
+ const newOffset = start + lastNewline + 1;
7335
+ return {
7336
+ events,
7337
+ newOffset,
7338
+ bytesRead,
7339
+ limitReached,
7340
+ malformedLines,
7341
+ incompleteTail: newOffset < start + bytesRead
7342
+ };
7343
+ }
7344
+ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DEFAULT_EVENT_READ_IO) {
7345
+ const start = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
7346
+ const aggregateCap = Number.isFinite(maxTotalBytes) && maxTotalBytes > 0 ? Math.max(1, Math.floor(maxTotalBytes)) : MAX_EVENT_SWEEP_BYTES;
7347
+ const events = [];
7348
+ let cursor = start;
7349
+ let bytesRead = 0;
7350
+ let batches = 0;
7351
+ let malformedLines = 0;
7352
+ while (bytesRead < aggregateCap) {
7353
+ const remainingBudget = aggregateCap - bytesRead;
7354
+ const batch = readEvents(
7355
+ log,
7356
+ cursor,
7357
+ Math.min(MAX_EVENT_READ_BYTES, remainingBudget),
7358
+ io
7359
+ );
7360
+ batches++;
7361
+ bytesRead += batch.bytesRead;
7362
+ malformedLines += batch.malformedLines;
7363
+ events.push(...batch.events);
7364
+ if (batch.malformedLines > 0) {
7365
+ return {
7366
+ events,
7367
+ newOffset: cursor,
7368
+ bytesRead,
7369
+ batches,
7370
+ malformedLines,
7371
+ complete: false,
7372
+ issue: "malformed-record"
7373
+ };
7374
+ }
7375
+ if (batch.bytesRead === 0) {
7376
+ if (batch.limitReached || batch.incompleteTail) {
7377
+ return {
7378
+ events,
7379
+ newOffset: cursor,
7380
+ bytesRead,
7381
+ batches,
7382
+ malformedLines,
7383
+ complete: false,
7384
+ issue: "read-stalled"
7385
+ };
7386
+ }
7387
+ return {
7388
+ events,
7389
+ newOffset: cursor,
7390
+ bytesRead,
7391
+ batches,
7392
+ malformedLines,
7393
+ complete: true
7394
+ };
7395
+ }
7396
+ if (batch.limitReached && batch.newOffset === cursor) {
7397
+ return {
7398
+ events,
7399
+ newOffset: cursor,
7400
+ bytesRead,
7401
+ batches,
7402
+ malformedLines,
7403
+ complete: false,
7404
+ issue: "oversized-record"
7405
+ };
7406
+ }
7407
+ if (!batch.limitReached && batch.incompleteTail) {
7408
+ return {
7409
+ events,
7410
+ newOffset: cursor,
7411
+ bytesRead,
7412
+ batches,
7413
+ malformedLines,
7414
+ complete: false,
7415
+ issue: "incomplete-record"
7416
+ };
7417
+ }
7418
+ cursor = batch.newOffset;
7419
+ if (!batch.limitReached) {
7420
+ return {
7421
+ events,
7422
+ newOffset: cursor,
7423
+ bytesRead,
7424
+ batches,
7425
+ malformedLines,
7426
+ complete: true
7427
+ };
7428
+ }
7429
+ if (bytesRead >= aggregateCap) {
7430
+ return {
7431
+ events,
7432
+ newOffset: cursor,
7433
+ bytesRead,
7434
+ batches,
7435
+ malformedLines,
7436
+ complete: false,
7437
+ issue: "aggregate-limit"
7438
+ };
7273
7439
  }
7274
7440
  }
7275
- return { events, newOffset: size };
7441
+ return {
7442
+ events,
7443
+ newOffset: cursor,
7444
+ bytesRead,
7445
+ batches,
7446
+ malformedLines,
7447
+ complete: false,
7448
+ issue: "aggregate-limit"
7449
+ };
7276
7450
  }
7277
7451
  function transientFindings(events, persistentPaths, policy, finalHash) {
7278
7452
  if (!isEnabled(RULE11, policy)) return [];
@@ -7316,7 +7490,7 @@ var HookInputError = class extends Error {
7316
7490
  };
7317
7491
  function readStdin() {
7318
7492
  try {
7319
- return readFileSync11(0, "utf8");
7493
+ return readFileSync10(0, "utf8");
7320
7494
  } catch (e) {
7321
7495
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
7322
7496
  }
@@ -7564,6 +7738,17 @@ function cursorPath(cwd, sessionId) {
7564
7738
  const p = ptreePath(cwd, sessionId);
7565
7739
  return p ? p.replace(/ptree-/, "fscursor-") : null;
7566
7740
  }
7741
+ function observerIntegrityBlock(issue, bytesRead, malformedLines) {
7742
+ const detail = issue === "malformed-record" ? `${malformedLines} malformed complete JSONL record(s)` : issue === "oversized-record" ? `one event record exceeded the ${MAX_EVENT_READ_BYTES}-byte per-record/read ceiling` : issue === "incomplete-record" ? "the event log ended with an incomplete JSONL record" : issue === "aggregate-limit" ? `more than ${MAX_EVENT_SWEEP_BYTES} observer bytes required classification in one Stop sweep` : "the positioned event-log read made no safe progress";
7743
+ return {
7744
+ rule: "tamperward-unavailable",
7745
+ severity: "block",
7746
+ message: `TamperWard could not fully classify transient observer telemetry (${detail}), so Stop is blocked rather than certifying an unjudged tail.`,
7747
+ evidence: `observer_issue=${issue}; bytes_read=${bytesRead}; malformed_lines=${malformedLines}`,
7748
+ remediation: "Let the watcher finish its current record or repair/rotate the malformed observer log, then retry Stop. Do not delete the cursor/log to bypass unjudged telemetry.",
7749
+ signoff: { required: true, command: 'tamperward allow --reason "..."' }
7750
+ };
7751
+ }
7567
7752
  function turnTransientBlocks(cwd, sessionId, policy, changes) {
7568
7753
  const none = { blocks: [], commit: () => {
7569
7754
  } };
@@ -7576,16 +7761,34 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
7576
7761
  if (!existsSync8(log)) return none;
7577
7762
  let offset = 0;
7578
7763
  if (cp && existsSync8(cp)) {
7579
- const n = Number(readFileSync11(cp, "utf8"));
7764
+ const n = Number(readFileSync10(cp, "utf8"));
7580
7765
  offset = Number.isFinite(n) ? n : 0;
7581
7766
  }
7582
- const { events, newOffset } = readEvents(log, offset);
7583
- if (events.length === 0) return { ...none, commit: () => {
7584
- if (cp) try {
7585
- writeFileSync5(cp, String(newOffset));
7586
- } catch {
7587
- }
7588
- } };
7767
+ const drained = drainEvents(log, offset);
7768
+ if (!drained.complete) {
7769
+ const issue = drained.issue ?? "read-stalled";
7770
+ recordObserverHealth(
7771
+ "degraded",
7772
+ `event telemetry was not fully classified (${issue}); Stop retained the prior cursor`
7773
+ );
7774
+ return {
7775
+ blocks: [observerIntegrityBlock(issue, drained.bytesRead, drained.malformedLines)],
7776
+ commit: () => {
7777
+ }
7778
+ };
7779
+ }
7780
+ const { events, newOffset } = drained;
7781
+ if (events.length === 0) {
7782
+ return {
7783
+ ...none,
7784
+ commit: () => {
7785
+ if (cp) try {
7786
+ writeFileSync5(cp, String(newOffset));
7787
+ } catch {
7788
+ }
7789
+ }
7790
+ };
7791
+ }
7589
7792
  const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
7590
7793
  const finalHash = (path) => {
7591
7794
  const e = inspectRel(cwd, path);
@@ -7742,7 +7945,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
7742
7945
  // src/cli/init.ts
7743
7946
  import { execFileSync as execFileSync6 } from "node:child_process";
7744
7947
  import { createHash as createHash6 } from "node:crypto";
7745
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync6, chmodSync, statSync as statSync2 } from "node:fs";
7948
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync6, chmodSync, statSync as statSync2 } from "node:fs";
7746
7949
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
7747
7950
 
7748
7951
  // src/verifier-limits.ts
@@ -7986,7 +8189,7 @@ function planClaudeHooks(cwd) {
7986
8189
  if (existsSync9(path)) {
7987
8190
  let parsed;
7988
8191
  try {
7989
- parsed = JSON.parse(readFileSync12(path, "utf8"));
8192
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
7990
8193
  } catch {
7991
8194
  return {
7992
8195
  item: "agent",
@@ -8132,7 +8335,7 @@ function planPreCommit(cwd) {
8132
8335
  if (existsSync9(path) && !statSync2(path).isFile()) {
8133
8336
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
8134
8337
  }
8135
- const existing = existsSync9(path) ? readFileSync12(path, "utf8") : null;
8338
+ const existing = existsSync9(path) ? readFileSync11(path, "utf8") : null;
8136
8339
  if (existing === null) {
8137
8340
  return {
8138
8341
  item: "pre-commit",
@@ -8224,7 +8427,7 @@ function planCodeowners(cwd) {
8224
8427
  (r) => existsSync9(join12(cwd, r))
8225
8428
  );
8226
8429
  const path = join12(cwd, existingRel ?? rel);
8227
- const existing = existingRel ? readFileSync12(path, "utf8") : null;
8430
+ const existing = existingRel ? readFileSync11(path, "utf8") : null;
8228
8431
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
8229
8432
  if (missing.length === 0) {
8230
8433
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -8273,7 +8476,7 @@ function planWorkflow(cwd, force) {
8273
8476
  }
8274
8477
  let src;
8275
8478
  try {
8276
- src = readFileSync12(path, "utf8");
8479
+ src = readFileSync11(path, "utf8");
8277
8480
  } catch {
8278
8481
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
8279
8482
  }
@@ -8372,7 +8575,7 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
8372
8575
 
8373
8576
  // src/cli/doctor.ts
8374
8577
  import { execFileSync as execFileSync7 } from "node:child_process";
8375
- import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync as readdirSync3 } from "node:fs";
8578
+ import { existsSync as existsSync10, readFileSync as readFileSync12, readdirSync as readdirSync3 } from "node:fs";
8376
8579
  import { join as join13, resolve as resolve4 } from "node:path";
8377
8580
  import { parse as parse3 } from "yaml";
8378
8581
  var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
@@ -8410,58 +8613,96 @@ function pinsInFile(path) {
8410
8613
  if (!existsSync10(path)) return [];
8411
8614
  let src = "";
8412
8615
  try {
8413
- src = readFileSync13(path, "utf8");
8616
+ src = readFileSync12(path, "utf8");
8414
8617
  } catch {
8415
8618
  return [];
8416
8619
  }
8417
8620
  return Array.from(src.matchAll(/\btamperward@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\b/g), (m) => m[1]);
8418
8621
  }
8419
- function workflowPermissionCheck(cwd) {
8420
- const rel = ".github/workflows/tamperward.yml";
8421
- const path = join13(cwd, rel);
8422
- if (!existsSync10(path)) return { id: "workflow-permissions", state: "BROKEN", detail: `${rel} is missing` };
8423
- let doc;
8424
- try {
8425
- doc = parse3(readFileSync13(path, "utf8"));
8426
- } catch (e) {
8427
- return {
8428
- id: "workflow-permissions",
8429
- state: "BROKEN",
8430
- detail: `${rel} is not valid YAML (${e instanceof Error ? e.message : String(e)})`
8431
- };
8622
+ function permissionWrites(value) {
8623
+ if (typeof value === "string") {
8624
+ if (/write/i.test(value)) return [value];
8625
+ if (/^(?:read-all|none)$/i.test(value)) return [];
8626
+ return null;
8432
8627
  }
8433
- const root = asMapping(doc);
8434
- const permissions = asMapping(root?.permissions);
8435
- if (!permissions) {
8436
- return {
8437
- id: "workflow-permissions",
8438
- state: "BROKEN",
8439
- detail: "workflow does not declare least-privilege permissions; repository defaults could grant write access"
8440
- };
8628
+ const mapping = asMapping(value);
8629
+ if (!mapping) return null;
8630
+ return Object.entries(mapping).filter(([, permission]) => typeof permission === "string" && /write/i.test(permission)).map(([name, permission]) => `${name}=${String(permission)}`);
8631
+ }
8632
+ function workflowPermissionCheck(cwd, workflowRels = [".github/workflows/tamperward.yml"]) {
8633
+ const broken = [];
8634
+ const warnings = [];
8635
+ for (const rel of workflowRels) {
8636
+ const path = resolve4(cwd, rel);
8637
+ if (!existsSync10(path)) {
8638
+ broken.push(`${rel} is missing`);
8639
+ continue;
8640
+ }
8641
+ let doc;
8642
+ try {
8643
+ doc = parse3(readFileSync12(path, "utf8"));
8644
+ } catch (e) {
8645
+ broken.push(`${rel} is not valid YAML (${e instanceof Error ? e.message : String(e)})`);
8646
+ continue;
8647
+ }
8648
+ const root = asMapping(doc);
8649
+ if (!root) {
8650
+ broken.push(`${rel} is not a YAML mapping`);
8651
+ continue;
8652
+ }
8653
+ const rootPermissions = root.permissions;
8654
+ if (rootPermissions === void 0) {
8655
+ broken.push(`${rel}: workflow does not declare least-privilege permissions; repository defaults could grant write access`);
8656
+ } else {
8657
+ const writes = permissionWrites(rootPermissions);
8658
+ if (writes === null) {
8659
+ broken.push(`${rel}: workflow permissions shape is unsupported`);
8660
+ } else if (writes.length) {
8661
+ broken.push(`${rel}: workflow grants write permission: ${writes.join(", ")}`);
8662
+ } else {
8663
+ const mapping = asMapping(rootPermissions);
8664
+ if (mapping && mapping.contents !== "read") {
8665
+ warnings.push(`${rel}: workflow permissions contain no writes but contents: read is not declared`);
8666
+ }
8667
+ }
8668
+ }
8669
+ const jobs = asMapping(root.jobs);
8670
+ if (!jobs) continue;
8671
+ for (const [name, rawJob] of Object.entries(jobs)) {
8672
+ const job = asMapping(rawJob);
8673
+ if (!job || job.permissions === void 0) continue;
8674
+ const writes = permissionWrites(job.permissions);
8675
+ if (writes === null) {
8676
+ broken.push(`${rel} job "${name}": permissions shape is unsupported`);
8677
+ } else if (writes.length) {
8678
+ broken.push(`${rel} job "${name}" grants write permission: ${writes.join(", ")}`);
8679
+ }
8680
+ }
8441
8681
  }
8442
- const writes = Object.entries(permissions).filter(
8443
- ([, value]) => typeof value === "string" && /write/i.test(value)
8444
- );
8445
- if (writes.length) {
8682
+ if (broken.length) {
8446
8683
  return {
8447
8684
  id: "workflow-permissions",
8448
8685
  state: "BROKEN",
8449
- detail: "workflow grants write permission: " + writes.map(([name]) => name).join(", ")
8686
+ detail: broken.join("; ")
8450
8687
  };
8451
8688
  }
8452
- if (permissions.contents !== "read") {
8689
+ if (warnings.length) {
8453
8690
  return {
8454
8691
  id: "workflow-permissions",
8455
8692
  state: "WARN",
8456
- detail: "workflow permissions are explicit but contents: read is not declared"
8693
+ detail: warnings.join("; ")
8457
8694
  };
8458
8695
  }
8459
- return { id: "workflow-permissions", state: "OK", detail: "workflow token is explicitly read-only (contents: read)" };
8696
+ return {
8697
+ id: "workflow-permissions",
8698
+ state: "OK",
8699
+ detail: `${workflowRels.join(", ")}: workflow/job token permissions contain no write grants and workflow contents access is read-only`
8700
+ };
8460
8701
  }
8461
- function collectLocalPosture(cwd, policy) {
8702
+ function collectLocalPosture(cwd, policy, authorityWorkflows, explicitWorkflow = false) {
8462
8703
  const checks = [];
8463
8704
  checks.push(
8464
- policy.version <= POLICY_VERSION ? { id: "policy", state: "OK", detail: `policy version ${policy.version} is understood (current schema ${POLICY_VERSION})` } : { id: "policy", state: "WARN", detail: `policy version ${policy.version} is newer than this binary's schema ${POLICY_VERSION}; only known gates can be evaluated` }
8705
+ policy.version <= POLICY_VERSION ? { id: "policy", state: "OK", detail: `policy version ${policy.version} is understood (current schema ${POLICY_VERSION})` } : { id: "policy", state: "BROKEN", detail: `policy version ${policy.version} is newer than this binary's schema ${POLICY_VERSION}; authority cannot be certified for unknown policy semantics` }
8465
8706
  );
8466
8707
  let plan = [];
8467
8708
  try {
@@ -8488,9 +8729,29 @@ function collectLocalPosture(cwd, policy) {
8488
8729
  };
8489
8730
  addWiring("claude-hooks", "agent", true);
8490
8731
  addWiring("pre-commit", "pre-commit", false);
8491
- addWiring("ci-wiring", "ci", true);
8732
+ const canonicalWorkflow = ".github/workflows/tamperward.yml";
8733
+ if (authorityWorkflows?.length) {
8734
+ const normalized = authorityWorkflows.map((x) => x.replace(/\\/g, "/"));
8735
+ if (explicitWorkflow) {
8736
+ checks.push({
8737
+ id: "ci-wiring",
8738
+ state: "OK",
8739
+ detail: `explicit verifier authority workflow: ${normalized.join(", ")}; verifier step/timeout validated by doctor`
8740
+ });
8741
+ } else if (normalized.includes(canonicalWorkflow)) {
8742
+ addWiring("ci-wiring", "ci", true);
8743
+ } else {
8744
+ checks.push({
8745
+ id: "ci-wiring",
8746
+ state: "OK",
8747
+ detail: `discovered verifier authority workflow(s): ${normalized.join(", ")}; verifier step/timeout validated by doctor`
8748
+ });
8749
+ }
8750
+ } else {
8751
+ addWiring("ci-wiring", "ci", true);
8752
+ }
8492
8753
  addWiring("codeowners", "codeowners", true);
8493
- checks.push(workflowPermissionCheck(cwd));
8754
+ checks.push(workflowPermissionCheck(cwd, authorityWorkflows));
8494
8755
  const pins = [
8495
8756
  ...pinsInFile(join13(cwd, ".claude", "settings.json")),
8496
8757
  ...pinsInFile(join13(cwd, ".git", "hooks", "pre-commit")),
@@ -8744,7 +9005,13 @@ function runDoctor(opts = {}) {
8744
9005
  "policy"
8745
9006
  );
8746
9007
  }
8747
- const fail = (id, message) => err(message, opts, id, collectLocalPosture(cwd, policy));
9008
+ let postureWorkflows = opts.workflow ? [opts.workflow] : void 0;
9009
+ const fail = (id, message) => err(
9010
+ message,
9011
+ opts,
9012
+ id,
9013
+ collectLocalPosture(cwd, policy, postureWorkflows, Boolean(opts.workflow))
9014
+ );
8748
9015
  if (!policy.verify?.command) {
8749
9016
  return fail("verifier", "trusted policy has no verify.command; generated CI cannot verify this repository");
8750
9017
  }
@@ -8771,6 +9038,7 @@ function runDoctor(opts = {}) {
8771
9038
  const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
8772
9039
  const requiredMinutes = Math.ceil(requiredSecs / 60);
8773
9040
  let verifyJobCount = 0;
9041
+ const authorityWorkflows = [];
8774
9042
  for (const workflowRel of workflowRels) {
8775
9043
  const workflowPath = resolve4(cwd, workflowRel);
8776
9044
  if (!existsSync10(workflowPath)) {
@@ -8778,7 +9046,7 @@ function runDoctor(opts = {}) {
8778
9046
  }
8779
9047
  let doc;
8780
9048
  try {
8781
- doc = parse3(readFileSync13(workflowPath, "utf8"));
9049
+ doc = parse3(readFileSync12(workflowPath, "utf8"));
8782
9050
  } catch (e) {
8783
9051
  return fail(
8784
9052
  "ci-verifier",
@@ -8787,6 +9055,10 @@ function runDoctor(opts = {}) {
8787
9055
  }
8788
9056
  const jobs = verifyJobs(doc);
8789
9057
  verifyJobCount += jobs.length;
9058
+ if (jobs.length > 0) {
9059
+ authorityWorkflows.push(workflowRel);
9060
+ postureWorkflows = [...authorityWorkflows];
9061
+ }
8790
9062
  for (const { name, job } of jobs) {
8791
9063
  const timeout = job["timeout-minutes"];
8792
9064
  if (timeout === void 0 || timeout === null) {
@@ -8845,7 +9117,12 @@ function runDoctor(opts = {}) {
8845
9117
  );
8846
9118
  }
8847
9119
  }
8848
- const checks = collectLocalPosture(cwd, policy);
9120
+ const checks = collectLocalPosture(
9121
+ cwd,
9122
+ policy,
9123
+ authorityWorkflows,
9124
+ Boolean(opts.workflow)
9125
+ );
8849
9126
  checks.push({
8850
9127
  id: "ci-verifier",
8851
9128
  state: "OK",
@@ -8877,7 +9154,7 @@ function runDoctor(opts = {}) {
8877
9154
  // src/cli/verify.ts
8878
9155
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
8879
9156
  import { createHash as createHash10 } from "node:crypto";
8880
- 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";
9157
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync5, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync7 } from "node:fs";
8881
9158
  import { tmpdir as tmpdir2 } from "node:os";
8882
9159
  import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
8883
9160
 
@@ -8913,7 +9190,7 @@ import {
8913
9190
  existsSync as existsSync11,
8914
9191
  lstatSync as lstatSync4,
8915
9192
  readdirSync as readdirSync4,
8916
- readFileSync as readFileSync14,
9193
+ readFileSync as readFileSync13,
8917
9194
  readlinkSync as readlinkSync2,
8918
9195
  realpathSync as realpathSync3,
8919
9196
  statSync as statSync3
@@ -8970,7 +9247,7 @@ function fingerprintRoot(root, cwd, h) {
8970
9247
  st = statSync3(path);
8971
9248
  if (!st.isFile()) throw new Error("not a regular file");
8972
9249
  h.update(label + "\0external-file\0" + path + "\0" + String(st.mode) + "\0");
8973
- h.update(readFileSync14(path));
9250
+ h.update(readFileSync13(path));
8974
9251
  h.update("\0");
8975
9252
  } catch (e) {
8976
9253
  throw new Unattestable(
@@ -9004,7 +9281,7 @@ function fingerprintRoot(root, cwd, h) {
9004
9281
  h.update(rel + "\0" + ident.kind + "\0" + String(ident.mode) + "\0");
9005
9282
  if (ident.kind === "file") {
9006
9283
  try {
9007
- h.update(readFileSync14(path));
9284
+ h.update(readFileSync13(path));
9008
9285
  h.update("\0");
9009
9286
  } catch (e) {
9010
9287
  throw new Unattestable(
@@ -9165,7 +9442,7 @@ function venvProblem(root) {
9165
9442
  const cfg = join14(root, "pyvenv.cfg");
9166
9443
  if (!existsSync11(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
9167
9444
  try {
9168
- const body = readFileSync14(cfg, "utf8");
9445
+ const body = readFileSync13(cfg, "utf8");
9169
9446
  if (/^\s*include-system-site-packages\s*=\s*true\s*$/im.test(body)) {
9170
9447
  return "Python environment " + JSON.stringify(root) + " enables system site-packages; those external package roots are not bounded";
9171
9448
  }
@@ -9263,7 +9540,7 @@ function dependencyEnvironmentSummary(descriptor) {
9263
9540
  // src/verifier-backend.ts
9264
9541
  import { createHash as createHash9, randomUUID } from "node:crypto";
9265
9542
  import { spawnSync } from "node:child_process";
9266
- import { accessSync, constants as constants2, readFileSync as readFileSync15, realpathSync as realpathSync4, statSync as statSync4 } from "node:fs";
9543
+ import { accessSync, constants as constants2, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync as statSync4 } from "node:fs";
9267
9544
  import { delimiter as delimiter2, dirname as dirname6, join as join15, resolve as resolve6 } from "node:path";
9268
9545
  var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
9269
9546
  memoryBytes: 2 * 1024 * 1024 * 1024,
@@ -9317,7 +9594,7 @@ function findExecutable(name) {
9317
9594
  return null;
9318
9595
  }
9319
9596
  function fileSha256(path) {
9320
- return createHash9("sha256").update(readFileSync15(path)).digest("hex");
9597
+ return createHash9("sha256").update(readFileSync14(path)).digest("hex");
9321
9598
  }
9322
9599
  function engineClientEnv() {
9323
9600
  return {
@@ -9965,7 +10242,7 @@ function overlayDigest(dest, paths) {
9965
10242
  try {
9966
10243
  const st = lstatSync5(p);
9967
10244
  h.update(String(st.mode));
9968
- h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync16(p));
10245
+ h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync15(p));
9969
10246
  } catch {
9970
10247
  h.update("<gone>");
9971
10248
  }
@@ -10076,7 +10353,7 @@ function runLocalSuite(dir, cmd, budgetSecs) {
10076
10353
  const secs = Math.round((Date.now() - t0) / 1e3);
10077
10354
  let r;
10078
10355
  try {
10079
- r = JSON.parse(readFileSync16(outFile, "utf8"));
10356
+ r = JSON.parse(readFileSync15(outFile, "utf8"));
10080
10357
  } catch {
10081
10358
  return { exit: null, secs };
10082
10359
  }
@@ -10379,7 +10656,7 @@ function parseVerify(args) {
10379
10656
  // src/cli/run.ts
10380
10657
  import { execFileSync as execFileSync10, spawn, spawnSync as spawnSync3 } from "node:child_process";
10381
10658
  import { randomBytes } from "node:crypto";
10382
- import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync17, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
10659
+ import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
10383
10660
  import { tmpdir as tmpdir3 } from "node:os";
10384
10661
  import { join as join17, resolve as resolve8 } from "node:path";
10385
10662
  var out = (s) => process.stdout.write(s + "\n");
@@ -10501,7 +10778,7 @@ function linuxDescendantPids(rootPid) {
10501
10778
  for (const name of names) {
10502
10779
  const pid = Number(name);
10503
10780
  try {
10504
- const stat = readFileSync17(`/proc/${name}/stat`, "utf8");
10781
+ const stat = readFileSync16(`/proc/${name}/stat`, "utf8");
10505
10782
  const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
10506
10783
  const ppid = Number(fields[1]);
10507
10784
  if (!Number.isFinite(ppid)) continue;
@@ -10570,7 +10847,7 @@ function runAgentWithBudget(argv2, cwd, budgetSecs) {
10570
10847
  supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
10571
10848
  let state = {};
10572
10849
  try {
10573
- state = JSON.parse(readFileSync17(resultFile, "utf8"));
10850
+ state = JSON.parse(readFileSync16(resultFile, "utf8"));
10574
10851
  } catch {
10575
10852
  }
10576
10853
  if (supervisorTimedOut || state.timedOut) {
@@ -10684,7 +10961,16 @@ function observerSummary(observer, telemetry) {
10684
10961
  }
10685
10962
  function collectObserverFindings(observer, telemetry, cwd, base, head, policy) {
10686
10963
  observerSummary(observer, telemetry);
10687
- const { events } = readEvents(observer.log, 0);
10964
+ const strict = process.env.TAMPERWARD_TRANSIENT === "block";
10965
+ const drained = drainEvents(observer.log, 0);
10966
+ const { events } = drained;
10967
+ if (!drained.complete) {
10968
+ const issue = drained.issue ?? "read-stalled";
10969
+ out(
10970
+ `tamperward run \u2014 transient observer: telemetry was not fully classified (${issue}; ${drained.bytesRead}/${MAX_EVENT_SWEEP_BYTES} bytes read). ` + (strict ? "Strict transient policy fails closed." : "Observer telemetry remains advisory, so this degrades evidence but does not independently fail the envelope.")
10971
+ );
10972
+ if (strict) return { blocking: true };
10973
+ }
10688
10974
  if (events.length === 0) return { blocking: false };
10689
10975
  let persistent = /* @__PURE__ */ new Set();
10690
10976
  try {
@@ -10697,9 +10983,9 @@ function collectObserverFindings(observer, telemetry, cwd, base, head, policy) {
10697
10983
  );
10698
10984
  } catch (e) {
10699
10985
  out(
10700
- `tamperward run \u2014 transient observer: recorded ${events.length} event(s), but could not classify them against the final diff (${e instanceof Error ? e.message : String(e)}).`
10986
+ `tamperward run \u2014 transient observer: recorded ${events.length} event(s), but could not classify them against the final diff (${e instanceof Error ? e.message : String(e)}).` + (strict ? " Strict transient policy fails closed." : "")
10701
10987
  );
10702
- return { blocking: false };
10988
+ return { blocking: strict };
10703
10989
  }
10704
10990
  const finalHash = (path) => {
10705
10991
  const e = inspectRel(cwd, path);
@@ -10723,7 +11009,7 @@ function git3(args, cwd) {
10723
11009
  }
10724
11010
  function nowTicks() {
10725
11011
  try {
10726
- return Math.floor(parseFloat(readFileSync17("/proc/uptime", "utf8").split(" ")[0]) * 100);
11012
+ return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
10727
11013
  } catch {
10728
11014
  return Number.POSITIVE_INFINITY;
10729
11015
  }
@@ -10746,7 +11032,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
10746
11032
  const n = Number(pid);
10747
11033
  if (n === process.pid) continue;
10748
11034
  try {
10749
- const stat = readFileSync17(`/proc/${pid}/stat`, "utf8");
11035
+ const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
10750
11036
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
10751
11037
  if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
10752
11038
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
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",