tamperward 2.16.4 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,9 +223,31 @@ verifier's `--budget`). A clean timeout is `AGENT_TIMEOUT` / exit 124; enforceme
223
223
  cannot-adjudicate still outranks it. **From 2.16.4, `tamperward run` is Linux-only
224
224
  for authoritative lifecycle certification.** Windows, macOS and other non-Linux platforms
225
225
  fail closed before the agent starts because this release has no OS primitive there that can
226
- prove the detached execution domain is drained. Standalone `tamperward check` and
227
- `tamperward verify` remain available on those platforms. Dependency-attestation reuse
228
- remains disabled on all platforms.
226
+ prove the detached execution domain is drained. From **2.16.5**, checkpointed-local
227
+ `tamperward verify` also has an explicit host-shell contract: Linux, macOS and the
228
+ supported POSIX Node platforms use `/bin/sh -c`; Windows local verification fails closed
229
+ before candidate execution rather than depending on an incidental MSYS/Git-for-Windows
230
+ `sh` on PATH. A digest-pinned container verifier is a separate backend and must pass its
231
+ own Docker authority preflight. Dependency-attestation reuse remains disabled on all
232
+ platforms.
233
+
234
+ ### Platform support
235
+
236
+ | Capability | Linux | macOS | Windows |
237
+ | --- | --- | --- | --- |
238
+ | `check` / policy evaluation | Supported | Supported | Supported |
239
+ | Claude hook / Stop adapter | Supported where Claude Code command hooks are available | Same | Same |
240
+ | `watch` / observer telemetry | Supported; backend health is reported | Supported/degraded according to `fs.watch` health | Supported/degraded according to `fs.watch` health |
241
+ | checkpointed-local `verify` | Supported via `/bin/sh` | Supported via `/bin/sh` | **Unsupported; fails before candidate execution** |
242
+ | isolated-container `verify` | Supported when Docker authority preflight passes | Not claimed beyond Docker preflight | Not claimed beyond Docker preflight |
243
+ | authoritative `run` | **Supported only with trusted non-root subreaper backend** | **Unsupported; fails before agent start** | **Unsupported; fails before agent start** |
244
+ | CI coverage for this contract | Full suite + platform contract | Platform-contract job | Platform-contract job |
245
+
246
+ The platform-contract CI job runs on real `ubuntu-latest`, `macos-latest` and
247
+ `windows-latest` hosts. It verifies the declared shell/lifecycle selection and proves
248
+ that Windows local verification refuses before a candidate command can produce a side
249
+ effect. This matrix is intentionally narrower than the Linux adversarial suite; it tests
250
+ the support boundary rather than implying feature parity where none is claimed.
229
251
 
230
252
  `tamperward verify` materialises two temporary copies — they are **not
231
253
  sandboxes**, and both execute agent-controlled code. The visible copy runs the
@@ -483,17 +505,20 @@ option can never be reinterpreted as the agent command.
483
505
 
484
506
  ### The rules
485
507
 
486
- Seventeen rules are specified and fifteen ship (see the table in
508
+ Seventeen rules are specified and sixteen ship (see the table in
487
509
  [SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
488
- `test-skip`, `test-content-removal`), verification-signal protection
510
+ `test-skip`, `test-content-removal`, plus the warning-only JS/TS
511
+ `assertion-weakening` heuristic), verification-signal protection
489
512
  (`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
490
513
  (`ts-any-cast`, `ts-any-launder`, `lint-suppression`), pipeline protection
491
514
  (`ci-tampering`, `hook-tampering`, `no-verify`), and the effect/outcome layers
492
515
  (`transient-protected-mutation`, `pristine-verification`, plus the `run` envelope).
493
- Some ambiguous syntactic classes deliberately remain warnings or unimplemented —
494
- `assertion-weakening` and `guard-removal` are reserved names with no detector, and
495
- `ts-any-launder` is a permanent warn — rather than being promoted to blockers
496
- without precision evidence.
516
+ `assertion-weakening` ships only the AST-proven one-way subset — statically
517
+ proven exact/structural values weakened to truthy/defined, positive exception
518
+ specificity removed, or a pure assertion removed from the same suite-qualified
519
+ test. It measured 12/12 true-positive fires with 0/20 false positives on the
520
+ committed detector-specific replay and remains `warn`; `guard-removal` is still
521
+ reserved with no detector, and `ts-any-launder` is a permanent warn.
497
522
 
498
523
  ## What Tamperward does not do
499
524
 
package/dist/cli/index.js CHANGED
@@ -6201,6 +6201,214 @@ var snapshotOnlyRewrite = {
6201
6201
  }
6202
6202
  };
6203
6203
 
6204
+ // src/detectors/assertion-weakening.ts
6205
+ import ts6 from "typescript";
6206
+ var RULE11 = "assertion-weakening";
6207
+ var TEST_CALLS = /* @__PURE__ */ new Set(["it", "test"]);
6208
+ var SUITE_CALLS = /* @__PURE__ */ new Set(["describe", "suite", "context"]);
6209
+ var EXACT_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual", "toMatchObject"]);
6210
+ var WEAK_MATCHERS = /* @__PURE__ */ new Set(["toBeTruthy", "toBeDefined"]);
6211
+ var compact = (s) => s.replace(/\s+/g, "");
6212
+ function literalTitle(node) {
6213
+ if (!node) return null;
6214
+ if (ts6.isStringLiteral(node) || ts6.isNoSubstitutionTemplateLiteral(node)) return node.text;
6215
+ return null;
6216
+ }
6217
+ function scriptKindForPath(path) {
6218
+ const lower = path.toLowerCase();
6219
+ if (lower.endsWith(".tsx")) return ts6.ScriptKind.TSX;
6220
+ if (lower.endsWith(".ts") || lower.endsWith(".mts") || lower.endsWith(".cts")) {
6221
+ return ts6.ScriptKind.TS;
6222
+ }
6223
+ if (lower.endsWith(".jsx")) return ts6.ScriptKind.JSX;
6224
+ return ts6.ScriptKind.JS;
6225
+ }
6226
+ function staticFacts(node) {
6227
+ if (!node) return null;
6228
+ if (node.kind === ts6.SyntaxKind.TrueKeyword) return { defined: true, truthy: true };
6229
+ if (node.kind === ts6.SyntaxKind.FalseKeyword) return { defined: true, truthy: false };
6230
+ if (node.kind === ts6.SyntaxKind.NullKeyword) return { defined: true, truthy: false };
6231
+ if (ts6.isIdentifier(node) && node.text === "undefined") {
6232
+ return { defined: false, truthy: false };
6233
+ }
6234
+ if (ts6.isVoidExpression(node)) return { defined: false, truthy: false };
6235
+ if (ts6.isStringLiteral(node) || ts6.isNoSubstitutionTemplateLiteral(node)) {
6236
+ return { defined: true, truthy: node.text.length > 0 };
6237
+ }
6238
+ if (ts6.isNumericLiteral(node)) {
6239
+ return { defined: true, truthy: Number(node.text) !== 0 };
6240
+ }
6241
+ if (ts6.isBigIntLiteral(node)) {
6242
+ return { defined: true, truthy: node.text.replace(/n$/i, "") !== "0" };
6243
+ }
6244
+ if (ts6.isPrefixUnaryExpression(node) && (node.operator === ts6.SyntaxKind.MinusToken || node.operator === ts6.SyntaxKind.PlusToken) && ts6.isNumericLiteral(node.operand)) {
6245
+ const value = Number(node.operand.text) * (node.operator === ts6.SyntaxKind.MinusToken ? -1 : 1);
6246
+ return { defined: true, truthy: value !== 0 };
6247
+ }
6248
+ if (ts6.isObjectLiteralExpression(node) || ts6.isArrayLiteralExpression(node) || ts6.isRegularExpressionLiteral(node) || ts6.isArrowFunction(node) || ts6.isFunctionExpression(node) || ts6.isClassExpression(node) || ts6.isNewExpression(node)) {
6249
+ return { defined: true, truthy: true };
6250
+ }
6251
+ return null;
6252
+ }
6253
+ function expectAssertion(node, sf) {
6254
+ if (!ts6.isPropertyAccessExpression(node.expression)) return null;
6255
+ const matcher = node.expression.name.text;
6256
+ let receiver = node.expression.expression;
6257
+ let polarity = "";
6258
+ if (ts6.isPropertyAccessExpression(receiver) && receiver.name.text === "not") {
6259
+ polarity = "not";
6260
+ receiver = receiver.expression;
6261
+ }
6262
+ if (!ts6.isCallExpression(receiver) || !ts6.isIdentifier(receiver.expression) || receiver.expression.text !== "expect" || receiver.arguments.length !== 1) {
6263
+ return null;
6264
+ }
6265
+ const subject = compact(receiver.arguments[0].getText(sf));
6266
+ const args = node.arguments.map((arg) => compact(arg.getText(sf)));
6267
+ return {
6268
+ subject,
6269
+ polarity,
6270
+ matcher,
6271
+ args,
6272
+ firstArgFacts: staticFacts(node.arguments[0]),
6273
+ canonical: [subject, polarity, matcher, ...args].join("|"),
6274
+ text: node.getText(sf).replace(/\s+/g, " ")
6275
+ };
6276
+ }
6277
+ function simpleCallName(node) {
6278
+ return ts6.isIdentifier(node.expression) ? node.expression.text : null;
6279
+ }
6280
+ function callbackFor(node) {
6281
+ const cb = node.arguments[1];
6282
+ return cb && (ts6.isArrowFunction(cb) || ts6.isFunctionExpression(cb)) ? cb : null;
6283
+ }
6284
+ function blocks(src, path) {
6285
+ const out2 = /* @__PURE__ */ new Map();
6286
+ if (langOf(path) !== "js") return out2;
6287
+ try {
6288
+ const sf = ts6.createSourceFile(path, src, ts6.ScriptTarget.Latest, true, scriptKindForPath(path));
6289
+ const visit = (node, suites) => {
6290
+ if (ts6.isCallExpression(node)) {
6291
+ const call = simpleCallName(node);
6292
+ const title = literalTitle(node.arguments[0]);
6293
+ const cb = callbackFor(node);
6294
+ if (call && SUITE_CALLS.has(call) && title !== null && cb) {
6295
+ const nextSuites = [...suites, title];
6296
+ ts6.forEachChild(cb, (child) => visit(child, nextSuites));
6297
+ return;
6298
+ }
6299
+ if (call && TEST_CALLS.has(call) && title !== null && cb) {
6300
+ const assertions = [];
6301
+ const collect2 = (child) => {
6302
+ if (child !== cb && ts6.isCallExpression(child)) {
6303
+ const nestedCall = simpleCallName(child);
6304
+ if (nestedCall && (TEST_CALLS.has(nestedCall) || SUITE_CALLS.has(nestedCall))) {
6305
+ return;
6306
+ }
6307
+ }
6308
+ if (ts6.isCallExpression(child)) {
6309
+ const a = expectAssertion(child, sf);
6310
+ if (a) assertions.push(a);
6311
+ }
6312
+ ts6.forEachChild(child, collect2);
6313
+ };
6314
+ collect2(cb);
6315
+ const identity2 = [...suites, title].join(" > ");
6316
+ const list2 = out2.get(identity2) ?? [];
6317
+ list2.push({ title, identity: identity2, assertions });
6318
+ out2.set(identity2, list2);
6319
+ return;
6320
+ }
6321
+ }
6322
+ ts6.forEachChild(node, (child) => visit(child, suites));
6323
+ };
6324
+ visit(sf, []);
6325
+ } catch {
6326
+ }
6327
+ return out2;
6328
+ }
6329
+ function pureRemoval(before, after) {
6330
+ if (after.length >= before.length) return null;
6331
+ const remaining = /* @__PURE__ */ new Map();
6332
+ for (const a of before) remaining.set(a.canonical, (remaining.get(a.canonical) ?? 0) + 1);
6333
+ for (const a of after) {
6334
+ const n = remaining.get(a.canonical) ?? 0;
6335
+ if (n <= 0) return null;
6336
+ remaining.set(a.canonical, n - 1);
6337
+ }
6338
+ for (const a of before) {
6339
+ const n = remaining.get(a.canonical) ?? 0;
6340
+ if (n > 0) return a;
6341
+ }
6342
+ return null;
6343
+ }
6344
+ function factsProveWeakMatcher(oldA, newMatcher) {
6345
+ if (!EXACT_MATCHERS.has(oldA.matcher) || oldA.args.length !== 1) return false;
6346
+ const facts = oldA.firstArgFacts;
6347
+ if (!facts) return false;
6348
+ if (newMatcher === "toBeDefined") return facts.defined;
6349
+ if (newMatcher === "toBeTruthy") return facts.truthy === true;
6350
+ return false;
6351
+ }
6352
+ function directionalWeakening(before, after) {
6353
+ for (const oldA of before) {
6354
+ for (const newA of after) {
6355
+ if (oldA.subject !== newA.subject) continue;
6356
+ if (oldA.polarity !== "" || newA.polarity !== "") continue;
6357
+ if (WEAK_MATCHERS.has(newA.matcher) && newA.args.length === 0 && factsProveWeakMatcher(oldA, newA.matcher)) {
6358
+ return { before: oldA, after: newA, kind: "matcher" };
6359
+ }
6360
+ if (oldA.matcher === "toThrow" && oldA.args.length > 0 && newA.matcher === "toThrow" && newA.args.length === 0) {
6361
+ return { before: oldA, after: newA, kind: "throw-specificity" };
6362
+ }
6363
+ }
6364
+ }
6365
+ return null;
6366
+ }
6367
+ function findingForBlock(path, before, after, policy) {
6368
+ const weakened = directionalWeakening(before.assertions, after.assertions);
6369
+ if (weakened) {
6370
+ const message = weakened.kind === "throw-specificity" ? `Assertion weakened in test "${before.identity}": exception-message specificity was removed from toThrow().` : `Assertion weakened in test "${before.identity}": ${weakened.before.matcher} was replaced by weaker ${weakened.after.matcher} on the same subject.`;
6371
+ return makeFinding(RULE11, policy, {
6372
+ file: path,
6373
+ message,
6374
+ evidence: `${weakened.before.text} -> ${weakened.after.text}`,
6375
+ remediation: "Keep the stronger expectation and fix the code under test. If the contract genuinely changed, review the test change explicitly; this rule is warning-only while its precision is measured."
6376
+ });
6377
+ }
6378
+ const removed = pureRemoval(before.assertions, after.assertions);
6379
+ if (removed) {
6380
+ return makeFinding(RULE11, policy, {
6381
+ file: path,
6382
+ message: `Assertion removed from kept test "${before.identity}" without a replacement assertion.`,
6383
+ evidence: removed.text,
6384
+ remediation: "Keep the assertion and fix the code under test. If the assertion is genuinely obsolete, review the test change explicitly; this rule is warning-only while its precision is measured."
6385
+ });
6386
+ }
6387
+ return null;
6388
+ }
6389
+ var assertionWeakening = {
6390
+ id: RULE11,
6391
+ surface: ["file"],
6392
+ certainty: "heuristic",
6393
+ run(changes, policy) {
6394
+ const out2 = [];
6395
+ for (const c of changes) {
6396
+ if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null || langOf(c.path) !== "js" || !isProtected(c.path, policy, "tests") || isProtected(c.path, policy, "snapshots")) {
6397
+ continue;
6398
+ }
6399
+ const beforeBlocks = blocks(c.before, c.path);
6400
+ const afterBlocks = blocks(c.after, c.path);
6401
+ for (const [identity2, oldList] of beforeBlocks) {
6402
+ const newList = afterBlocks.get(identity2);
6403
+ if (!newList || oldList.length !== 1 || newList.length !== 1) continue;
6404
+ const f = findingForBlock(c.path, oldList[0], newList[0], policy);
6405
+ if (f) out2.push(f);
6406
+ }
6407
+ }
6408
+ return out2;
6409
+ }
6410
+ };
6411
+
6204
6412
  // src/detectors/index.ts
6205
6413
  var allDetectors = [
6206
6414
  noVerify,
@@ -6213,7 +6421,8 @@ var allDetectors = [
6213
6421
  testDeletion,
6214
6422
  testContentRemoval,
6215
6423
  snapshotRewrite,
6216
- snapshotOnlyRewrite
6424
+ snapshotOnlyRewrite,
6425
+ assertionWeakening
6217
6426
  ];
6218
6427
 
6219
6428
  // src/engine.ts
@@ -6575,9 +6784,9 @@ function orderFindings(findings) {
6575
6784
  return (a.line ?? 0) - (b.line ?? 0);
6576
6785
  });
6577
6786
  }
6578
- function countsPhrase(blocks, warns) {
6787
+ function countsPhrase(blocks2, warns) {
6579
6788
  const parts = [];
6580
- if (blocks) parts.push(`${blocks} blocking`);
6789
+ if (blocks2) parts.push(`${blocks2} blocking`);
6581
6790
  if (warns) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
6582
6791
  return parts.join(", ");
6583
6792
  }
@@ -6589,7 +6798,7 @@ var SIGNOFF_NOTE = "A blocking finding clears only with a human sign-off. In CI
6589
6798
  function renderText(input, opts) {
6590
6799
  const { scanned, ignoredFiles } = input;
6591
6800
  const findings = orderFindings(input.findings);
6592
- const blocks = findings.filter((f) => f.severity === "block");
6801
+ const blocks2 = findings.filter((f) => f.severity === "block");
6593
6802
  const warns = findings.filter((f) => f.severity === "warn");
6594
6803
  const c = opts.colour;
6595
6804
  const out2 = [];
@@ -6597,8 +6806,8 @@ function renderText(input, opts) {
6597
6806
  return `${paint("tamperward: clean", GREEN, c)} \u2014 no integrity findings (${scopePhrase(scanned, ignoredFiles)}).
6598
6807
  `;
6599
6808
  }
6600
- const headColour = blocks.length ? RED : YELLOW;
6601
- out2.push(paint(`tamperward: ${countsPhrase(blocks.length, warns.length)}`, BOLD + headColour, c));
6809
+ const headColour = blocks2.length ? RED : YELLOW;
6810
+ out2.push(paint(`tamperward: ${countsPhrase(blocks2.length, warns.length)}`, BOLD + headColour, c));
6602
6811
  out2.push(paint(`(${scopePhrase(scanned, ignoredFiles)})`, DIM, c));
6603
6812
  out2.push("");
6604
6813
  const LABEL = "sign-off".length;
@@ -6618,7 +6827,7 @@ function renderText(input, opts) {
6618
6827
  if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
6619
6828
  out2.push("");
6620
6829
  }
6621
- if (blocks.length > 0) {
6830
+ if (blocks2.length > 0) {
6622
6831
  out2.push(
6623
6832
  wrap(SIGNOFF_NOTE, opts.width - 2).map((l) => paint(l, DIM, c)).join("\n")
6624
6833
  );
@@ -6668,7 +6877,7 @@ function locationCell(f, env) {
6668
6877
  }
6669
6878
  function renderSummary(input, env = process.env) {
6670
6879
  const findings = orderFindings(input.findings);
6671
- const blocks = findings.filter((f) => f.severity === "block");
6880
+ const blocks2 = findings.filter((f) => f.severity === "block");
6672
6881
  const warns = findings.filter((f) => f.severity === "warn");
6673
6882
  const scope = scopePhrase(input.scanned, input.ignoredFiles);
6674
6883
  if (findings.length === 0) {
@@ -6678,7 +6887,7 @@ No integrity findings (${scope}).
6678
6887
  `;
6679
6888
  }
6680
6889
  const out2 = [
6681
- `## Tamperward: ${countsPhrase(blocks.length, warns.length)}`,
6890
+ `## Tamperward: ${countsPhrase(blocks2.length, warns.length)}`,
6682
6891
  "",
6683
6892
  `${scope}. Every finding below is also annotated on the diff in **Files changed**.`,
6684
6893
  "",
@@ -6731,12 +6940,12 @@ function report(input) {
6731
6940
  const { findings, scanned, ignoredFiles } = input;
6732
6941
  const format = resolveFormat(input);
6733
6942
  if (format === "json") {
6734
- const blocks = findings.filter((f) => f.severity === "block").length;
6943
+ const blocks2 = findings.filter((f) => f.severity === "block").length;
6735
6944
  process.stdout.write(
6736
6945
  JSON.stringify(
6737
6946
  // `findings`, `scanned` and `ignoredFiles` keep their exact prior shape; `summary`
6738
6947
  // is additive, so an existing consumer reading only the old keys is unaffected.
6739
- { findings, scanned, ignoredFiles, summary: { block: blocks, warn: findings.length - blocks } },
6948
+ { findings, scanned, ignoredFiles, summary: { block: blocks2, warn: findings.length - blocks2 } },
6740
6949
  null,
6741
6950
  2
6742
6951
  ) + "\n"
@@ -6833,10 +7042,10 @@ import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writ
6833
7042
  import { createHash as createHash5 } from "node:crypto";
6834
7043
 
6835
7044
  // src/adapters/claude/deny.ts
6836
- function formatDenial(blocks) {
6837
- const lead = blocks[0];
7045
+ function formatDenial(blocks2) {
7046
+ const lead = blocks2[0];
6838
7047
  const lines = ["Tamperward blocked this change \u2014 it weakens a protected safety net to pass checks.", ""];
6839
- for (const f of blocks) {
7048
+ for (const f of blocks2) {
6840
7049
  const loc = f.file ? ` (${escapeControl(f.file)}${f.line ? `:${f.line}` : ""})` : "";
6841
7050
  lines.push(` \u2022 ${f.rule}${loc}: ${f.message}`);
6842
7051
  }
@@ -6845,7 +7054,7 @@ function formatDenial(blocks) {
6845
7054
  lines.push(
6846
7055
  "The other shortcuts are blocked too: skipping the hooks, editing the hook or CI workflow, lowering the coverage gate, and rewriting a protected file from the shell will each be denied."
6847
7056
  );
6848
- if (blocks.some((b) => b.signoff.required)) {
7057
+ if (blocks2.some((b) => b.signoff.required)) {
6849
7058
  lines.push(`If this change is genuinely correct, a human must sign off: ${lead.signoff.command}`);
6850
7059
  }
6851
7060
  return lines.join("\n") + "\n";
@@ -7263,7 +7472,7 @@ function runWatch(args) {
7263
7472
 
7264
7473
  // src/detectors/fs-events.ts
7265
7474
  import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync } from "node:fs";
7266
- var RULE11 = "transient-protected-mutation";
7475
+ var RULE12 = "transient-protected-mutation";
7267
7476
  var MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
7268
7477
  var MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
7269
7478
  var DEFAULT_EVENT_READ_IO = {
@@ -7458,7 +7667,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
7458
7667
  };
7459
7668
  }
7460
7669
  function transientFindings(events, persistentPaths, policy, finalHash) {
7461
- if (!isEnabled(RULE11, policy)) return [];
7670
+ if (!isEnabled(RULE12, policy)) return [];
7462
7671
  const byPath = /* @__PURE__ */ new Map();
7463
7672
  for (const e of events) {
7464
7673
  if (!isProtected(e.path, policy)) continue;
@@ -7478,7 +7687,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
7478
7687
  const modeChurn = modes.size > 1;
7479
7688
  if (!contentChurn && !modeChurn) continue;
7480
7689
  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 ")})`;
7481
- const f = makeFinding(RULE11, policy, {
7690
+ const f = makeFinding(RULE12, policy, {
7482
7691
  file: path,
7483
7692
  message: `Transient protected mutation: ${what}, leaving no net diff.`,
7484
7693
  evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
@@ -7517,11 +7726,11 @@ function parseInput(raw) {
7517
7726
  }
7518
7727
  return parsed;
7519
7728
  }
7520
- function recordDenylog(blocks) {
7729
+ function recordDenylog(blocks2) {
7521
7730
  const log = process.env.TAMPERWARD_DENYLOG;
7522
7731
  if (!log) return;
7523
7732
  try {
7524
- appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
7733
+ appendFileSync4(log, blocks2.map((b) => b.rule).join(",") + "\n");
7525
7734
  } catch {
7526
7735
  }
7527
7736
  }
@@ -7540,10 +7749,10 @@ function failClosed(kind, detail) {
7540
7749
  };
7541
7750
  return verdict([f], kind);
7542
7751
  }
7543
- function verdict(blocks, kind) {
7544
- if (blocks.length === 0) return { exitCode: 0, stdout: "" };
7545
- recordDenylog(blocks);
7546
- const reason = formatDenial(blocks);
7752
+ function verdict(blocks2, kind) {
7753
+ if (blocks2.length === 0) return { exitCode: 0, stdout: "" };
7754
+ recordDenylog(blocks2);
7755
+ const reason = formatDenial(blocks2);
7547
7756
  const payload = kind === "PreToolUse" ? {
7548
7757
  hookSpecificOutput: {
7549
7758
  hookEventName: "PreToolUse",
@@ -7600,14 +7809,14 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
7600
7809
  if (c.oldPath) seen.add(c.oldPath);
7601
7810
  }
7602
7811
  const changes = [];
7603
- const blocks = [];
7812
+ const blocks2 = [];
7604
7813
  const trusted = base ?? "HEAD";
7605
7814
  const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
7606
7815
  for (const rel of [...d.changed, ...d.deleted]) {
7607
7816
  if (seen.has(rel)) continue;
7608
7817
  const unjudgeable = unjudgeableFinding(cwd, rel);
7609
7818
  if (unjudgeable) {
7610
- blocks.push(unjudgeable);
7819
+ blocks2.push(unjudgeable);
7611
7820
  continue;
7612
7821
  }
7613
7822
  const after = fileOnDisk(rel, { cwd });
@@ -7627,7 +7836,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
7627
7836
  }
7628
7837
  }
7629
7838
  if (before == null) {
7630
- blocks.push({
7839
+ blocks2.push({
7631
7840
  rule: "hidden-drift",
7632
7841
  severity: "block",
7633
7842
  file: rel,
@@ -7660,12 +7869,12 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
7660
7869
  if (seen.has(rel)) continue;
7661
7870
  const unjudgeable = unjudgeableFinding(cwd, rel);
7662
7871
  if (unjudgeable) {
7663
- blocks.push(unjudgeable);
7872
+ blocks2.push(unjudgeable);
7664
7873
  continue;
7665
7874
  }
7666
7875
  const after = fileOnDisk(rel, { cwd });
7667
7876
  if (after == null) {
7668
- blocks.push({
7877
+ blocks2.push({
7669
7878
  rule: "hidden-drift",
7670
7879
  severity: "block",
7671
7880
  file: rel,
@@ -7678,7 +7887,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
7678
7887
  }
7679
7888
  changes.push(...synthFileChange(rel, null, after));
7680
7889
  }
7681
- return { changes, blocks };
7890
+ return { changes, blocks: blocks2 };
7682
7891
  }
7683
7892
  function effectDriftBlocks(cwd, sessionId, policy) {
7684
7893
  if (!sessionId || !isGitRepo(cwd)) return null;
@@ -7703,8 +7912,8 @@ function effectDriftBlocks(cwd, sessionId, policy) {
7703
7912
  );
7704
7913
  const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
7705
7914
  const judged = [...scoped, ...gap.changes];
7706
- const blocks = evaluate(judged, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks, unjudgeableProtected(cwd, policy, judged));
7707
- if (blocks.length > 0) return blocks;
7915
+ const blocks2 = evaluate(judged, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks, unjudgeableProtected(cwd, policy, judged));
7916
+ if (blocks2.length > 0) return blocks2;
7708
7917
  savePtree(cwd, sessionId, current);
7709
7918
  return null;
7710
7919
  }
@@ -7736,9 +7945,9 @@ function preToolUseVerdict(input) {
7736
7945
  const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
7737
7946
  if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
7738
7947
  const changes = changesFromClaudeHook(input, cwd);
7739
- const blocks = evaluate(changes, policy, void 0, "tool-call", { cwd }).filter((f) => f.severity === "block");
7740
- if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
7741
- return verdict(blocks, "PreToolUse");
7948
+ const blocks2 = evaluate(changes, policy, void 0, "tool-call", { cwd }).filter((f) => f.severity === "block");
7949
+ if (blocks2.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
7950
+ return verdict(blocks2, "PreToolUse");
7742
7951
  } catch (e) {
7743
7952
  return failClosed("PreToolUse", errText2(e));
7744
7953
  }
@@ -7840,7 +8049,7 @@ function stopVerdict(input) {
7840
8049
  if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
7841
8050
  const cwd = input.cwd ?? process.cwd();
7842
8051
  if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
7843
- let blocks;
8052
+ let blocks2;
7844
8053
  let commitCursor = () => {
7845
8054
  };
7846
8055
  try {
@@ -7855,22 +8064,22 @@ function stopVerdict(input) {
7855
8064
  changes = changes.concat(gap.changes);
7856
8065
  hiddenBlocks = gap.blocks;
7857
8066
  }
7858
- blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks, unjudgeableProtected(cwd, policy, changes));
8067
+ blocks2 = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks, unjudgeableProtected(cwd, policy, changes));
7859
8068
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
7860
- blocks = blocks.concat(transient.blocks);
8069
+ blocks2 = blocks2.concat(transient.blocks);
7861
8070
  commitCursor = transient.commit;
7862
- if (blocks.length === 0 && input.session_id && current) {
8071
+ if (blocks2.length === 0 && input.session_id && current) {
7863
8072
  savePtree(cwd, input.session_id, current);
7864
8073
  saveTurnTree(cwd, input.session_id, current);
7865
8074
  }
7866
8075
  } catch (e) {
7867
8076
  return failClosed("Stop", errText2(e));
7868
8077
  }
7869
- if (blocks.length === 0) {
8078
+ if (blocks2.length === 0) {
7870
8079
  advanceTurnBaseline(cwd, input.session_id);
7871
8080
  commitCursor();
7872
8081
  }
7873
- return verdict(blocks, "Stop");
8082
+ return verdict(blocks2, "Stop");
7874
8083
  }
7875
8084
  function emit(r) {
7876
8085
  if (r.stdout) process.stdout.write(r.stdout);
@@ -10218,15 +10427,37 @@ function suiteEnv(scratch) {
10218
10427
  env.YARN_IGNORE_PATH = "1";
10219
10428
  return env;
10220
10429
  }
10430
+ function localVerifierShell(platform = process.platform, command) {
10431
+ switch (platform) {
10432
+ case "linux":
10433
+ case "darwin":
10434
+ case "freebsd":
10435
+ case "openbsd":
10436
+ case "aix":
10437
+ case "sunos":
10438
+ return { executable: "/bin/sh", args: ["-c", command] };
10439
+ default:
10440
+ return null;
10441
+ }
10442
+ }
10221
10443
  function runLocalSuite(dir, cmd, budgetSecs) {
10222
10444
  const t0 = Date.now();
10445
+ const shell = localVerifierShell(process.platform, cmd);
10446
+ if (!shell) {
10447
+ return {
10448
+ exit: null,
10449
+ secs: 0,
10450
+ failure: "backend",
10451
+ reason: `checkpointed-local verifier is unsupported on ${process.platform}; no trusted local shell contract is defined`
10452
+ };
10453
+ }
10223
10454
  const scratch = mkdtempSync3(join16(tmpdir3(), "tw-verify-run-"));
10224
10455
  try {
10225
- const r = runCapturedProcessSync("sh", ["-c", cmd], {
10456
+ const r = runCapturedProcessSync(shell.executable, shell.args, {
10226
10457
  cwd: dir,
10227
10458
  env: suiteEnv(scratch),
10228
10459
  timeoutMs: budgetSecs * 1e3,
10229
- detached: process.platform !== "win32",
10460
+ detached: true,
10230
10461
  killGroupOnFinish: true,
10231
10462
  backstopMs: 3e4
10232
10463
  });
@@ -10339,6 +10570,22 @@ function runVerify(opts) {
10339
10570
  return 2;
10340
10571
  }
10341
10572
  const isolated = verifierBackend.kind === "container";
10573
+ if (!isolated && !localVerifierShell(process.platform, cmd)) {
10574
+ if (opts.json) {
10575
+ out2(JSON.stringify({
10576
+ verdict: "CANNOT_VERIFY",
10577
+ reason: "LOCAL_VERIFIER_UNSUPPORTED_PLATFORM",
10578
+ platform: process.platform,
10579
+ verifier_backend: backendReport(),
10580
+ oracle_assurance: oracleAssuranceReport()
10581
+ }));
10582
+ } else {
10583
+ out2(
10584
+ `verify: checkpointed-local verifier is unsupported on ${process.platform}; no trusted local shell contract is defined. Failing closed before candidate execution.`
10585
+ );
10586
+ }
10587
+ return 2;
10588
+ }
10342
10589
  const dependencyEnvironment = isolated ? null : opts.dependencyEnvironment ?? discoverDependencyEnvironment(cwd, cmd);
10343
10590
  const dependencyReport = () => dependencyEnvironment ? dependencyEnvironmentReport(dependencyEnvironment) : {
10344
10591
  status: "verifier-owned",
@@ -10604,6 +10851,12 @@ var err = (s) => process.stderr.write(s + "\n");
10604
10851
  function authoritativeRunLifecyclePlatform(platform = process.platform) {
10605
10852
  return platform === "linux";
10606
10853
  }
10854
+ function waitForSettleSync(seconds) {
10855
+ if (!Number.isFinite(seconds) || seconds <= 0) return;
10856
+ const ms = Math.ceil(seconds * 1e3);
10857
+ const word = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
10858
+ Atomics.wait(word, 0, 0, ms);
10859
+ }
10607
10860
  function canReuseAdjacentDependencyAttestation(_lifecycleOwned, _platform = process.platform) {
10608
10861
  return false;
10609
10862
  }
@@ -11216,8 +11469,9 @@ function runEnvelope(opts) {
11216
11469
  }
11217
11470
  const lifecyclePlatform = opts.lifecyclePlatformOverride ?? process.platform;
11218
11471
  if (!authoritativeRunLifecyclePlatform(lifecyclePlatform)) {
11472
+ const alternative = lifecyclePlatform === "win32" ? "Use standalone tamperward check; checkpointed-local verify is unsupported on Windows, while container verify must pass its own Docker authority preflight." : "Use standalone tamperward check/verify or an isolated execution domain.";
11219
11473
  err(
11220
- `tamperward run: authoritative agent lifecycle ownership is unavailable on ${lifecyclePlatform}; this release only certifies run on Linux with the trusted subreaper/ECHILD backend. Failing closed before the agent starts. Use standalone tamperward check/verify or an isolated execution domain.`
11474
+ `tamperward run: authoritative agent lifecycle ownership is unavailable on ${lifecyclePlatform}; this release only certifies run on Linux with the trusted subreaper/ECHILD backend. Failing closed before the agent starts. ${alternative}`
11221
11475
  );
11222
11476
  return 2;
11223
11477
  }
@@ -11339,10 +11593,7 @@ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEME
11339
11593
  verifierBackend
11340
11594
  });
11341
11595
  if (opts.settle && opts.settle > 0) {
11342
- try {
11343
- execFileSync9("sleep", [String(opts.settle)]);
11344
- } catch {
11345
- }
11596
+ waitForSettleSync(opts.settle);
11346
11597
  }
11347
11598
  const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
11348
11599
  if (mutatedDuringAdjudication) {
@@ -11438,7 +11689,7 @@ function lifecyclePlatformCheck(platform = process.platform, linuxPython = platf
11438
11689
  return {
11439
11690
  id: "platform",
11440
11691
  state: "BROKEN",
11441
- detail: "Windows: authoritative tamperward run lifecycle ownership is unavailable; taskkill /T /F is only a best-effort tree fallback, so run fails closed before agent start. Standalone check/verify remain available."
11692
+ detail: "Windows: authoritative tamperward run lifecycle ownership is unavailable; taskkill /T /F is only a best-effort tree fallback, so run fails closed before agent start. Standalone check remains available; checkpointed-local verify is unsupported because this release defines verifier command semantics only through POSIX /bin/sh. A container verifier must pass its own Docker authority preflight."
11442
11693
  };
11443
11694
  }
11444
11695
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.16.4",
3
+ "version": "2.17.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",