tamperward 2.31.1 → 2.33.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
@@ -239,11 +239,13 @@ node harness/perf/bench.mjs # performance budgets (docs/PERF.md)
239
239
  ```
240
240
 
241
241
  Tamperward's own CI runs the engine it ships over every pull request — `check --diff`
242
- over the PR range, cleared only by an out-of-band label — and has, on more than one
242
+ over the PR range, cleared only by an out-of-band exact-head label (generate one with
243
+ `tamperward signoff-label`) — and has, on more than one
243
244
  occasion, blocked its own author's commits. Branch, then open a PR; `main` is
244
245
  protected and CI must be green. Changing a protected asset will block your own PR
245
246
  (working as intended); a reviewed, legitimate change is cleared by a maintainer
246
- applying a `tamperward:allow:<rule>` label, never by weakening the policy.
247
+ applying a compact `tw1:<digest>` label, never by weakening the policy. Legacy full-SHA
248
+ tokens remain accepted by the parser for CI systems whose label transport can hold them.
247
249
 
248
250
  The public surface is the CLI and its exit codes, the hook wire format, the
249
251
  `.tamperward.yml` schema, and the versioned machine-output schemas under `schemas/` —
package/dist/cli/index.js CHANGED
@@ -8932,7 +8932,7 @@ They WILL FAIL in v10.0.0
8932
8932
  makeFinding(RULE9, policy, {
8933
8933
  file: c.path,
8934
8934
  message: `A protected hook script was added that does not run the gate live: ${why}. A new gate script needs a sign-off unless the gate is live in it.`,
8935
- evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or make the gate live in it`,
8935
+ evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or generate the compact CI label with \`tamperward signoff-label --rule hook-tampering --file ${c.path} --head <full-sha>\`, or make the gate live in it`,
8936
8936
  remediation: "Run `tamperward check --staged` in the new hook, in a position where its failure fails the hook, or have a human sign off on a hook that does not."
8937
8937
  })
8938
8938
  );
@@ -8963,7 +8963,7 @@ They WILL FAIL in v10.0.0
8963
8963
  makeFinding(RULE9, policy, {
8964
8964
  file: c.path,
8965
8965
  message: `A hand-written protected hook script was changed: ${detail.length ? detail.join("; ") : "an edit other than a pin raise"}. Every edit to a gate script other than raising its pin needs a sign-off.`,
8966
- evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or restore it`,
8966
+ evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or generate the compact CI label with \`tamperward signoff-label --rule hook-tampering --file ${c.path} --head <full-sha>\`, or restore it`,
8967
8967
  remediation: "Restore the script, or have a human sign off. A line-by-line reading of a shell script cannot tell an honest restructuring from a neutered gate, so the gate script is held byte-for-byte: only raising its pin passes without a sign-off."
8968
8968
  })
8969
8969
  );
@@ -10869,6 +10869,12 @@ var init_policy_load = __esm({
10869
10869
  import { createHash as createHash3 } from "node:crypto";
10870
10870
  import { appendFileSync, mkdirSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "node:fs";
10871
10871
  import { dirname as dirname2, join as join9 } from "node:path";
10872
+ function compactOobToken(want, head) {
10873
+ const normalizedHead = head.trim().toLowerCase();
10874
+ if (!want || !FULL_OBJECT_ID.test(normalizedHead)) return null;
10875
+ const digest = createHash3("sha256").update(`tamperward:oob:v1\0${want}\0${normalizedHead}`).digest("base64url");
10876
+ return `${COMPACT_OOB_PREFIX}${digest}`;
10877
+ }
10872
10878
  function ledgerEntryFrom(value) {
10873
10879
  if (!isRecord(value)) return null;
10874
10880
  const { rule, file, fingerprint: fingerprint2, reason, recordedAt, expiresAt } = value;
@@ -10924,6 +10930,11 @@ function oobToken(want, oob, head) {
10924
10930
  for (const raw of oob) {
10925
10931
  const t = raw.trim();
10926
10932
  if (!t) continue;
10933
+ if (t.startsWith(COMPACT_OOB_PREFIX)) {
10934
+ const expected = head === void 0 ? null : compactOobToken(want, head);
10935
+ if (expected !== null && t === expected) return t;
10936
+ continue;
10937
+ }
10927
10938
  const at = t.lastIndexOf("@");
10928
10939
  if (at === -1) {
10929
10940
  if (!head && t === want) return t;
@@ -10956,13 +10967,15 @@ function oobHeadFromEnv(env = process.env) {
10956
10967
  function oobFromEnv(env = process.env) {
10957
10968
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
10958
10969
  }
10959
- var DEFAULT_TTL_MS, fingerprintOf;
10970
+ var DEFAULT_TTL_MS, COMPACT_OOB_PREFIX, FULL_OBJECT_ID, fingerprintOf;
10960
10971
  var init_signoff = __esm({
10961
10972
  "src/signoff.ts"() {
10962
10973
  "use strict";
10963
10974
  init_policy_load();
10964
10975
  init_narrow();
10965
10976
  DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
10977
+ COMPACT_OOB_PREFIX = "tw1:";
10978
+ FULL_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
10966
10979
  fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
10967
10980
  }
10968
10981
  });
@@ -14206,9 +14219,10 @@ version: 1
14206
14219
 
14207
14220
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
14208
14221
  # the PR's commit range. A block fails the check and clears ONLY via the out-of-band
14209
- # label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with triage access or higher \u2014
14210
- # never a file the PR itself can commit. The verify step reads the same labels:
14211
- # \`tamperward:allow:verify@<head-sha>\` accepts a masked failure a reviewer has judged.
14222
+ # compact label \`tw1:<digest>\` (print one with \`tamperward signoff-label\`) applied by
14223
+ # someone with triage access or higher \u2014 never a file the PR itself can commit. Legacy
14224
+ # \`tamperward:allow:<rule>@<head-sha>\` labels remain accepted. The verify step reads
14225
+ # the same labels: a token for \`verify\` accepts a masked failure a reviewer has judged.
14212
14226
  #
14213
14227
  # labeled/unlabeled re-run the gate because the sign-off is read from the EVENT payload:
14214
14228
  # a label applied after a failure could otherwise never take effect, and REVOKING a
@@ -14252,14 +14266,15 @@ jobs:
14252
14266
  env:
14253
14267
  LABELS: \${{ toJSON(github.event.pull_request.labels.*.name) }}
14254
14268
  run: |
14255
- RULES="$(printf '%s' "$LABELS" | jq -r '.[] | select(startswith("tamperward:allow:")) | sub("^tamperward:allow:"; "")' | paste -sd, -)"
14269
+ RULES="$(printf '%s' "$LABELS" | jq -r '.[] | if startswith("tamperward:allow:") then sub("^tamperward:allow:"; "") elif startswith("tw1:") then . else empty end' | paste -sd, -)"
14256
14270
  echo "rules=$RULES" >> "$GITHUB_OUTPUT"
14257
14271
  - name: Tamperward gate (diff-time)
14258
14272
  env:
14259
14273
  TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
14260
14274
  # Binds each approval to the commit it was granted for: labels persist
14261
14275
  # across pushes, so an unbound one would clear every later finding on
14262
- # the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
14276
+ # the same PR. Compact labels hash the exact rule/file/head tuple; legacy
14277
+ # labels must read tamperward:allow:<rule>@<head-sha>.
14263
14278
  TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
14264
14279
  run: tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
14265
14280
  # Diff-time detection is spelling-dependent by nature; pristine
@@ -14272,8 +14287,9 @@ jobs:
14272
14287
  # without one this step fails closed (exit 2) rather than passing quietly.
14273
14288
  - name: Tamperward verify (pristine re-execution)
14274
14289
  env:
14275
- # The same channel as the gate. \`tamperward:allow:verify@<head-sha>\`
14276
- # accepts a MASKED_FAILURE \u2014 a reviewer has read the intentional test
14290
+ # The same channel as the gate. A compact token generated for \`verify\`
14291
+ # (or legacy \`tamperward:allow:verify@<head-sha>\`) accepts a MASKED_FAILURE \u2014
14292
+ # a reviewer has read the intentional test
14277
14293
  # change and agrees the original suite no longer applies. It clears
14278
14294
  # nothing else: a red visible suite, or a run that could not verify,
14279
14295
  # stays red whatever the labels say.
@@ -16398,10 +16414,12 @@ function runVerify(opts) {
16398
16414
  renderStageDiagnostics(out3, "pristine", pristine);
16399
16415
  }
16400
16416
  }
16401
- if (signedOff)
16417
+ if (signedOff) {
16418
+ const approval = signedOff.startsWith("tw1:") ? signedOff : `tamperward:allow:${signedOff}`;
16402
16419
  out3(
16403
- `masked failure cleared by out-of-band approval (tamperward:allow:${signedOff}): a reviewer accepted that the original suite no longer applies to this change. Exit 0.`
16420
+ `masked failure cleared by out-of-band approval (${approval}): a reviewer accepted that the original suite no longer applies to this change. Exit 0.`
16404
16421
  );
16422
+ }
16405
16423
  if (removedAdded > 0)
16406
16424
  out3(
16407
16425
  `(${removedAdded} protected file(s) added since ${base.slice(0, 10)} were removed from the pristine run: the pristine tree carries exactly the base's protected surface.)`
@@ -19715,8 +19733,10 @@ var init_record = __esm({
19715
19733
  });
19716
19734
 
19717
19735
  // src/research/run.ts
19736
+ import { randomUUID as randomUUID2 } from "node:crypto";
19718
19737
  import { execFileSync as execFileSync13 } from "node:child_process";
19719
- import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync23, renameSync as renameSync4, rmSync as rmSync11, writeFileSync as writeFileSync11 } from "node:fs";
19738
+ import { closeSync as closeSync3, existsSync as existsSync18, linkSync, mkdirSync as mkdirSync9, openSync as openSync3, readFileSync as readFileSync23, renameSync as renameSync4, rmSync as rmSync11, unlinkSync, writeFileSync as writeFileSync11, writeSync } from "node:fs";
19739
+ import { hostname } from "node:os";
19720
19740
  import { join as join25, resolve as resolve15 } from "node:path";
19721
19741
  function git6(args, cwd) {
19722
19742
  return execFileSync13("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -19724,6 +19744,102 @@ function git6(args, cwd) {
19724
19744
  function pairRecordPath(ledger, task, pair) {
19725
19745
  return join25(ledger, "pairs", `${task}--${pair}.json`);
19726
19746
  }
19747
+ function researchLockPath(ledger) {
19748
+ return join25(ledger, "run.lock");
19749
+ }
19750
+ function readLockOwner(path) {
19751
+ try {
19752
+ const parsed = JSON.parse(readFileSync23(path, "utf8"));
19753
+ if (!isRecord(parsed) || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
19754
+ if (typeof parsed.host !== "string" || typeof parsed.started_at !== "string" || typeof parsed.token !== "string") return null;
19755
+ return { pid: parsed.pid, host: parsed.host, started_at: parsed.started_at, token: parsed.token };
19756
+ } catch {
19757
+ return null;
19758
+ }
19759
+ }
19760
+ function processAlive(pid) {
19761
+ try {
19762
+ process.kill(pid, 0);
19763
+ return true;
19764
+ } catch (e) {
19765
+ return e.code !== "ESRCH";
19766
+ }
19767
+ }
19768
+ function lockHolder(path) {
19769
+ const owner = readLockOwner(path);
19770
+ return owner ? `pid ${owner.pid} on ${owner.host}, started ${owner.started_at}` : "an unreadable lock file";
19771
+ }
19772
+ function acquireResearchLock(ledger, breakStaleLock = false) {
19773
+ mkdirSync9(ledger, { recursive: true });
19774
+ const path = researchLockPath(ledger);
19775
+ if (breakStaleLock && existsSync18(path)) {
19776
+ const owner2 = readLockOwner(path);
19777
+ if (!owner2) {
19778
+ throw new ResearchError(`research output ${ledger} has an unreadable lock at ${path}; verify no run is active, then remove it deliberately`);
19779
+ }
19780
+ if (owner2.host !== hostname() || processAlive(owner2.pid)) {
19781
+ throw new ResearchError(
19782
+ `research output ${ledger} is still locked by ${lockHolder(path)}; refusing to break an active lock (a reused pid looks alive: if that owner is certainly gone, remove ${path} deliberately)`
19783
+ );
19784
+ }
19785
+ const claimed = `${path}.stale-${process.pid}-${randomUUID2()}`;
19786
+ let moved = true;
19787
+ try {
19788
+ renameSync4(path, claimed);
19789
+ } catch (e) {
19790
+ if (e.code !== "ENOENT") {
19791
+ throw new ResearchError(`research output ${ledger} stale lock recovery failed for ${path}`);
19792
+ }
19793
+ moved = false;
19794
+ }
19795
+ if (moved) {
19796
+ const inspected = readLockOwner(claimed);
19797
+ if (inspected && inspected.token === owner2.token) {
19798
+ unlinkSync(claimed);
19799
+ } else {
19800
+ try {
19801
+ linkSync(claimed, path);
19802
+ unlinkSync(claimed);
19803
+ } catch {
19804
+ }
19805
+ throw new ResearchError(`research output ${ledger}: the lock at ${path} changed while it was being broken; refusing (rerun --break-lock only once no run is active)`);
19806
+ }
19807
+ }
19808
+ }
19809
+ const owner = {
19810
+ pid: process.pid,
19811
+ host: hostname(),
19812
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
19813
+ token: randomUUID2()
19814
+ };
19815
+ const text = JSON.stringify(owner) + "\n";
19816
+ let fd = -1;
19817
+ try {
19818
+ fd = openSync3(path, "wx", 384);
19819
+ writeSync(fd, text);
19820
+ closeSync3(fd);
19821
+ fd = -1;
19822
+ } catch (e) {
19823
+ if (fd !== -1) closeSync3(fd);
19824
+ if (e.code === "EEXIST") {
19825
+ throw new ResearchError(
19826
+ `research output ${ledger} is already locked by ${lockHolder(path)}; refusing concurrent writers. Verify the owner is not running, then remove ${path} deliberately or rerun with --break-lock`
19827
+ );
19828
+ }
19829
+ throw new ResearchError(`research output ${ledger} cannot create lock ${path}`);
19830
+ }
19831
+ let released = false;
19832
+ return {
19833
+ release() {
19834
+ if (released) return;
19835
+ released = true;
19836
+ try {
19837
+ if (readFileSync23(path, "utf8") === text) unlinkSync(path);
19838
+ } catch {
19839
+ }
19840
+ }
19841
+ };
19842
+ }
19727
19843
  function resumableRecord(path, expected) {
19728
19844
  let raw;
19729
19845
  try {
@@ -20012,84 +20128,46 @@ function runResearch(opts) {
20012
20128
  return 2;
20013
20129
  }
20014
20130
  const ledger = resolve15(opts.out);
20015
- const pairs = opts.pairs ?? 1;
20016
- mkdirSync9(join25(ledger, "pairs"), { recursive: true });
20017
- for (const task of tasks) {
20018
- const existingRecords = /* @__PURE__ */ new Map();
20019
- let sourceBase = null;
20020
- try {
20021
- for (let pair = 1; pair <= pairs; pair++) {
20022
- const path = pairRecordPath(ledger, task.id, pair);
20023
- if (!existsSync18(path)) continue;
20024
- const existing = resumableRecord(path, {
20025
- task: task.id,
20026
- pair,
20027
- manifest_sha256: manifestSha,
20028
- adapter: { name: adapter.name, layers: adapter.layers },
20029
- model: opts.model ?? null,
20030
- tamperward_version: TW_VERSION,
20031
- agent_argv: agentArgvIdentity,
20032
- agent_budget: opts.agentBudget ?? null,
20033
- verify_command: task.verify.command,
20034
- source_base: sourceBase
20035
- });
20036
- sourceBase ??= existing.arms.ungated.base;
20037
- if (existing.arms.ungated.base !== sourceBase) {
20038
- throw new ResearchError(
20039
- `ledger record ${path} belongs to a different source commit (${existing.arms.ungated.base.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026); use a new --out, or remove it deliberately`
20040
- );
20041
- }
20042
- existingRecords.set(pair, existing);
20043
- }
20044
- } catch (e) {
20045
- if (e instanceof ResearchError) {
20046
- err2(`tamperward research: ${e.message}`);
20047
- return 2;
20048
- }
20049
- throw e;
20131
+ let lock;
20132
+ try {
20133
+ lock = acquireResearchLock(ledger, opts.breakLock === true);
20134
+ } catch (e) {
20135
+ if (e instanceof ResearchError) {
20136
+ err2(`tamperward research: ${e.message}`);
20137
+ return 2;
20050
20138
  }
20051
- for (let pair = 1; pair <= pairs; pair++) {
20052
- const path = pairRecordPath(ledger, task.id, pair);
20053
- const existing = existingRecords.get(pair);
20054
- if (existing) {
20055
- if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: already recorded (${path}); skipping`);
20056
- continue;
20057
- }
20058
- let record;
20139
+ throw e;
20140
+ }
20141
+ try {
20142
+ const pairs = opts.pairs ?? 1;
20143
+ mkdirSync9(join25(ledger, "pairs"), { recursive: true });
20144
+ for (const task of tasks) {
20145
+ const existingRecords = /* @__PURE__ */ new Map();
20146
+ let sourceBase = null;
20059
20147
  try {
20060
- const arms = {};
20061
- for (const arm of RESEARCH_ARMS) {
20062
- if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: ${arm} arm`);
20063
- arms[arm] = runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase ?? void 0);
20064
- if (arm === "ungated") {
20065
- const resolvedSource = arms[arm]?.base;
20066
- if (!resolvedSource) throw new ResearchError(`task "${task.id}" pair ${pair}: ungated arm produced no source base`);
20067
- if (sourceBase !== null && resolvedSource !== sourceBase) {
20068
- throw new ResearchError(
20069
- `task "${task.id}" pair ${pair}: source base moved (${resolvedSource.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026)`
20070
- );
20071
- }
20072
- sourceBase ??= resolvedSource;
20148
+ for (let pair = 1; pair <= pairs; pair++) {
20149
+ const path = pairRecordPath(ledger, task.id, pair);
20150
+ if (!existsSync18(path)) continue;
20151
+ const existing = resumableRecord(path, {
20152
+ task: task.id,
20153
+ pair,
20154
+ manifest_sha256: manifestSha,
20155
+ adapter: { name: adapter.name, layers: adapter.layers },
20156
+ model: opts.model ?? null,
20157
+ tamperward_version: TW_VERSION,
20158
+ agent_argv: agentArgvIdentity,
20159
+ agent_budget: opts.agentBudget ?? null,
20160
+ verify_command: task.verify.command,
20161
+ source_base: sourceBase
20162
+ });
20163
+ sourceBase ??= existing.arms.ungated.base;
20164
+ if (existing.arms.ungated.base !== sourceBase) {
20165
+ throw new ResearchError(
20166
+ `ledger record ${path} belongs to a different source commit (${existing.arms.ungated.base.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026); use a new --out, or remove it deliberately`
20167
+ );
20073
20168
  }
20169
+ existingRecords.set(pair, existing);
20074
20170
  }
20075
- const ungated = arms.ungated;
20076
- const gated = arms.gated;
20077
- if (!ungated || !gated) throw new ResearchError(`task "${task.id}" pair ${pair}: an arm produced no record`);
20078
- record = {
20079
- schema_version: MACHINE_SCHEMA_VERSION,
20080
- command: "research",
20081
- document: "pair",
20082
- task: task.id,
20083
- pair,
20084
- adapter: { name: adapter.name, layers: [...adapter.layers] },
20085
- model: opts.model ?? null,
20086
- tamperward_version: TW_VERSION,
20087
- agent_argv: [...agentArgvIdentity],
20088
- agent_budget: opts.agentBudget ?? null,
20089
- manifest_sha256: manifestSha,
20090
- verify_command: task.verify.command,
20091
- arms: { ungated, gated }
20092
- };
20093
20171
  } catch (e) {
20094
20172
  if (e instanceof ResearchError) {
20095
20173
  err2(`tamperward research: ${e.message}`);
@@ -20097,20 +20175,72 @@ function runResearch(opts) {
20097
20175
  }
20098
20176
  throw e;
20099
20177
  }
20100
- const text = JSON.stringify(record);
20101
- writeRecordAtomically(path, text + "\n");
20102
- if (opts.json) {
20103
- out2(text);
20104
- } else {
20105
- const g = record.arms.gated;
20106
- const u = record.arms.ungated;
20107
- out2(
20108
- `tamperward research \u2014 task ${task.id} pair ${pair}: ungated ${u.outcome.verify_verdict} (masked=${u.outcome.masked_failure}, surviving=${u.outcome.surviving_protected_mutations}); gated ${g.outcome.verify_verdict} (masked=${g.outcome.masked_failure}, surviving=${g.outcome.surviving_protected_mutations}), tamperward ${g.treatment?.verdict ?? "n/a"} \u2192 ${g.treatment?.disposition ?? "n/a"}` + (u.measured && g.measured ? "" : `; UNMEASURABLE (${[u.unmeasurable, g.unmeasurable].filter(Boolean).join(" / ")})`) + `; recorded ${path}`
20109
- );
20178
+ for (let pair = 1; pair <= pairs; pair++) {
20179
+ const path = pairRecordPath(ledger, task.id, pair);
20180
+ const existing = existingRecords.get(pair);
20181
+ if (existing) {
20182
+ if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: already recorded (${path}); skipping`);
20183
+ continue;
20184
+ }
20185
+ let record;
20186
+ try {
20187
+ const arms = {};
20188
+ for (const arm of RESEARCH_ARMS) {
20189
+ if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: ${arm} arm`);
20190
+ arms[arm] = runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase ?? void 0);
20191
+ if (arm === "ungated") {
20192
+ const resolvedSource = arms[arm]?.base;
20193
+ if (!resolvedSource) throw new ResearchError(`task "${task.id}" pair ${pair}: ungated arm produced no source base`);
20194
+ if (sourceBase !== null && resolvedSource !== sourceBase) {
20195
+ throw new ResearchError(
20196
+ `task "${task.id}" pair ${pair}: source base moved (${resolvedSource.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026)`
20197
+ );
20198
+ }
20199
+ sourceBase ??= resolvedSource;
20200
+ }
20201
+ }
20202
+ const ungated = arms.ungated;
20203
+ const gated = arms.gated;
20204
+ if (!ungated || !gated) throw new ResearchError(`task "${task.id}" pair ${pair}: an arm produced no record`);
20205
+ record = {
20206
+ schema_version: MACHINE_SCHEMA_VERSION,
20207
+ command: "research",
20208
+ document: "pair",
20209
+ task: task.id,
20210
+ pair,
20211
+ adapter: { name: adapter.name, layers: [...adapter.layers] },
20212
+ model: opts.model ?? null,
20213
+ tamperward_version: TW_VERSION,
20214
+ agent_argv: [...agentArgvIdentity],
20215
+ agent_budget: opts.agentBudget ?? null,
20216
+ manifest_sha256: manifestSha,
20217
+ verify_command: task.verify.command,
20218
+ arms: { ungated, gated }
20219
+ };
20220
+ } catch (e) {
20221
+ if (e instanceof ResearchError) {
20222
+ err2(`tamperward research: ${e.message}`);
20223
+ return 2;
20224
+ }
20225
+ throw e;
20226
+ }
20227
+ const text = JSON.stringify(record);
20228
+ writeRecordAtomically(path, text + "\n");
20229
+ if (opts.json) {
20230
+ out2(text);
20231
+ } else {
20232
+ const g = record.arms.gated;
20233
+ const u = record.arms.ungated;
20234
+ out2(
20235
+ `tamperward research \u2014 task ${task.id} pair ${pair}: ungated ${u.outcome.verify_verdict} (masked=${u.outcome.masked_failure}, surviving=${u.outcome.surviving_protected_mutations}); gated ${g.outcome.verify_verdict} (masked=${g.outcome.masked_failure}, surviving=${g.outcome.surviving_protected_mutations}), tamperward ${g.treatment?.verdict ?? "n/a"} \u2192 ${g.treatment?.disposition ?? "n/a"}` + (u.measured && g.measured ? "" : `; UNMEASURABLE (${[u.unmeasurable, g.unmeasurable].filter(Boolean).join(" / ")})`) + `; recorded ${path}`
20236
+ );
20237
+ }
20110
20238
  }
20111
20239
  }
20240
+ return 0;
20241
+ } finally {
20242
+ lock.release();
20112
20243
  }
20113
- return 0;
20114
20244
  }
20115
20245
  var err2, out2;
20116
20246
  var init_run2 = __esm({
@@ -20296,6 +20426,7 @@ function parseResearchRun(args) {
20296
20426
  else if (a === "--pairs") o.pairs = Number(args[++i]);
20297
20427
  else if (a === "--model") o.model = args[++i];
20298
20428
  else if (a === "--agent-budget") o.agentBudget = Number(args[++i]);
20429
+ else if (a === "--break-lock") o.breakLock = true;
20299
20430
  else if (a === "--json") o.json = true;
20300
20431
  }
20301
20432
  return o;
@@ -20325,6 +20456,33 @@ var init_research = __esm({
20325
20456
  }
20326
20457
  });
20327
20458
 
20459
+ // src/cli/signoff-label.ts
20460
+ function runSignoffLabel(opts) {
20461
+ if (!opts.rule) {
20462
+ process.stderr.write("tamperward signoff-label --rule <rule> --head <full-head-sha> [--file <path>]\n");
20463
+ return 2;
20464
+ }
20465
+ if (!opts.head) {
20466
+ process.stderr.write("tamperward: --head is required and must be a full 40- or 64-character object id.\n");
20467
+ return 2;
20468
+ }
20469
+ const want = opts.file ? `${opts.rule}:${opts.file}` : opts.rule;
20470
+ const token = compactOobToken(want, opts.head);
20471
+ if (!token) {
20472
+ process.stderr.write("tamperward: --head must be a full 40- or 64-character hexadecimal object id.\n");
20473
+ return 2;
20474
+ }
20475
+ process.stdout.write(`${token}
20476
+ `);
20477
+ return 0;
20478
+ }
20479
+ var init_signoff_label = __esm({
20480
+ "src/cli/signoff-label.ts"() {
20481
+ "use strict";
20482
+ init_signoff();
20483
+ }
20484
+ });
20485
+
20328
20486
  // src/cli/main.ts
20329
20487
  var main_exports = {};
20330
20488
  __export(main_exports, {
@@ -20404,6 +20562,16 @@ function parseStats(args) {
20404
20562
  }
20405
20563
  return o;
20406
20564
  }
20565
+ function parseSignoffLabel(args) {
20566
+ const o = {};
20567
+ for (let i = 0; i < args.length; i++) {
20568
+ const a = args[i];
20569
+ if (a === "--rule") o.rule = args[++i];
20570
+ else if (a === "--file") o.file = args[++i];
20571
+ else if (a === "--head") o.head = args[++i];
20572
+ }
20573
+ return o;
20574
+ }
20407
20575
  function parseCheck(args) {
20408
20576
  const o = {};
20409
20577
  for (let i = 0; i < args.length; i++) {
@@ -20525,6 +20693,11 @@ function validateCliArgs(cmd, args) {
20525
20693
  if (parsed.positionals.length === 0) return "allow requires a rule";
20526
20694
  return void 0;
20527
20695
  }
20696
+ if (cmd === "signoff-label") {
20697
+ return validateFlatArgs(args, {
20698
+ values: { "--rule": "string", "--file": "string", "--head": "string" }
20699
+ }).error;
20700
+ }
20528
20701
  if (cmd === "init") {
20529
20702
  return validateFlatArgs(args, {
20530
20703
  flags: ["--dry-run", "--force-workflow"],
@@ -20608,7 +20781,7 @@ function validateCliArgs(cmd, args) {
20608
20781
  const delimiter3 = rest.indexOf("--");
20609
20782
  const prefix = delimiter3 < 0 ? rest : rest.slice(0, delimiter3);
20610
20783
  const parsed = validateFlatArgs(prefix, {
20611
- flags: ["--json"],
20784
+ flags: ["--break-lock", "--json"],
20612
20785
  values: {
20613
20786
  "--manifest": "string",
20614
20787
  "--out": "string",
@@ -20677,8 +20850,8 @@ Formats:
20677
20850
  [--budget S] [--json] [--keep] as-is AND with protected files restored
20678
20851
  [--require-ancestor] [--cwd D] from the trusted base; a visible-green /
20679
20852
  pristine-red result is a MASKED FAILURE
20680
- (exit 1, or 0 under an out-of-band
20681
- verify@<head-sha> approval); cannot-verify
20853
+ (exit 1, or 0 under a compact tw1:<digest>
20854
+ or legacy verify@<full-sha> approval); cannot-verify
20682
20855
  fails closed (2)
20683
20856
  tamperward trace-verify [--base R] advisory Linux verifier-input discovery:
20684
20857
  [--cmd C] [--budget S] [--runs N] trace a trusted/known-good base with
@@ -20707,9 +20880,11 @@ Formats:
20707
20880
  tamperward research run --manifest F bring-your-own-model evaluation: for every
20708
20881
  --out D --adapter A [--pairs N] task in the manifest, pin one source commit,
20709
20882
  [--model M] [--agent-budget S] clone fresh state per arm, run the agent
20710
- [--json] [-- <agent cmd...>] ungated and under the run envelope, then
20711
- observe both with verify + check. Records
20883
+ [--break-lock] [--json] ungated and under the run envelope, then
20884
+ [-- <agent cmd...>] observe both with verify + check. Records
20712
20885
  are resumable by full experiment identity.
20886
+ --break-lock recovers a verified stale
20887
+ output lock (see the research guide).
20713
20888
  tamperward research summarize --ledger D aggregate measured pairs into model behaviour,
20714
20889
  independent outcome, TamperWard hits/misses
20715
20890
  and paired counts \u2014 no composite score
@@ -20721,6 +20896,8 @@ Formats:
20721
20896
  integrity signal, not proof of agent intent.
20722
20897
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
20723
20898
  [--file F] [--cwd D]
20899
+ tamperward signoff-label --rule <rule> --head <sha> [--file F]
20900
+ print a compact, exact-head-bound CI label
20724
20901
  tamperward onboard [--yes] [--cwd D] guided first-run setup: preflight, the
20725
20902
  [--base R] [--repo O/R] init plan with a confirmation before any
20726
20903
  [--branch B] [--verify-command C] write, canonical init, explicit verifier
@@ -20785,6 +20962,8 @@ function main(argv) {
20785
20962
  return runAgentCommand("sweep", rest);
20786
20963
  case "allow":
20787
20964
  return runAllow(parseAllow(rest));
20965
+ case "signoff-label":
20966
+ return runSignoffLabel(parseSignoffLabel(rest));
20788
20967
  case "init":
20789
20968
  return runInit(parseInit(rest));
20790
20969
  case "doctor":
@@ -20853,6 +21032,7 @@ var init_main = __esm({
20853
21032
  init_onboard();
20854
21033
  init_research();
20855
21034
  init_audit();
21035
+ init_signoff_label();
20856
21036
  }
20857
21037
  });
20858
21038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.31.1",
3
+ "version": "2.33.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",