tamperward 2.11.2 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -12
  2. package/dist/cli/index.js +556 -133
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -155,7 +155,15 @@ After the runtime exits its exit code is treated as untrusted, and the envelope
155
155
  checks that post-agent `HEAD` still descends from the entry commit; the committed
156
156
  changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
157
157
  changes; dependency drift and whether the tree stayed quiescent; and the
158
- verification outcome.
158
+ verification outcome. For agent runtimes that may hang, `--agent-budget <seconds>`
159
+ adds an operator-owned wall-clock boundary around the **agent itself** (separate from
160
+ the verifier's `--budget`). On expiry TamperWard terminates the owned process tree,
161
+ then still performs the same post-timeout adjudication. A clean timeout is
162
+ `AGENT_TIMEOUT` / exit 124; any enforcement failure or cannot-adjudicate result
163
+ still outranks that runtime status. On POSIX the agent starts in its own process
164
+ group; on Linux the timeout also enumerates the agent's `/proc` descendant tree so
165
+ a child that escaped the group with `setsid()` is terminated before adjudication.
166
+ Windows uses `taskkill /T /F` as the explicit process-tree fallback.
159
167
 
160
168
  `tamperward verify` materialises two temporary copies — they are **not
161
169
  sandboxes**, and both execute agent-controlled code. The visible copy runs the
@@ -175,6 +183,7 @@ semantic correctness nor a substitute for independent held-out tests.
175
183
  | Enforcement clean and agent exits 0 | exit 0 |
176
184
  | Blocking finding, masked failure, dependency drift, or non-quiescent tree | exit 1 |
177
185
  | Required adjudication cannot be completed | exit 2, failing closed |
186
+ | Enforcement clean but the agent exceeded `--agent-budget` | `AGENT_TIMEOUT`, exit 124 |
178
187
  | Enforcement clean but the agent failed | the agent's non-zero exit is preserved |
179
188
 
180
189
  ### CI authority
@@ -190,6 +199,16 @@ are involved here, and they are not the same thing.
190
199
  its policy, verification surface and verifier command and budget from that same
191
200
  trusted base — so neither step's verdict is governed by the candidate.
192
201
 
202
+ Since **2.12.0**, the generated job uses GitHub Actions' 360-minute outer timeout and
203
+ runs `tamperward doctor` before verification. The doctor reads the **trusted base**
204
+ policy, finds the job(s) that actually invoke `tamperward verify`, and requires enough
205
+ static outer time for both independently budgeted stages plus a 60-minute authority
206
+ reserve. With the generated 360-minute job that means budgets up to 9,000 seconds per
207
+ stage fit. Larger positive finite policy budgets remain valid — there is no schema cap —
208
+ but this generated GitHub-hosted authority refuses deterministically and tells you how
209
+ much outer time is required, so a custom runner/workflow can provide it instead of
210
+ GitHub killing verification mid-verdict.
211
+
193
212
  Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
194
213
  The workflow passes the head SHA to both steps through `TAMPERWARD_OOB_HEAD`, so an
195
214
  approval is bound to the exact commit it was granted for and a new push invalidates it.
@@ -294,7 +313,7 @@ The four primitives:
294
313
  npx tamperward check --staged # pre-commit view
295
314
  npx tamperward check --diff "main...HEAD" # CI view over the PR's commit range
296
315
  npx tamperward verify --base main # pristine-suite re-execution
297
- npx tamperward run -- <agent command...> # same-host envelope (local verifier only)
316
+ npx tamperward run --agent-budget 1800 -- <agent command...> # optional agent-runtime bound
298
317
  ```
299
318
 
300
319
  The isolated backend is a **frozen-artifact final verifier**. `tamperward run`
@@ -312,7 +331,8 @@ commands ignore what they do not know.
312
331
  | --- | --- |
313
332
  | `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
314
333
  | `verify` | `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--json` · `--keep` (keep the two materialised copies and report their paths) · `--require-ancestor` (refuse a base that is not an ancestor of `HEAD`) · `--cwd <dir>` |
315
- | `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
334
+ | `doctor` | `--base <rev>` (trusted policy revision) · `--workflow <path>` (default `.github/workflows/tamperward.yml`) · `--cwd <dir>` — validates every job that runs `tamperward verify` has a static outer timeout covering visible + pristine + authority reserve |
335
+ | `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
316
336
  | `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
317
337
  | `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
318
338
  | `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
@@ -320,15 +340,16 @@ commands ignore what they do not know.
320
340
 
321
341
  **Exit codes** — part of the public surface:
322
342
 
323
- | command | 0 | 1 | 2 |
324
- | --- | --- | --- | --- |
325
- | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 |
326
- | `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
327
- | `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
328
- | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
329
- | `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
330
- | `init` | wired, or already wired | — | an item needs attention |
331
- | no or unknown command | help printed (no command) | — | unknown command, help printed |
343
+ | command | 0 | 1 | 2 | 124 |
344
+ | --- | --- | --- | --- | --- |
345
+ | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 | — |
346
+ | `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run | — |
347
+ | `doctor` | configured verify job(s) have sufficient static outer time for the trusted policy | — | missing/invalid workflow, no verify job, missing/malformed/insufficient timeout, or trusted policy cannot be loaded | — |
348
+ | `run` | enforcement clean and the agent exited 0 — another non-zero agent exit is passed through unchanged | any blocking finding or masked failure, including a non-quiescent process after timeout | cannot adjudicate: dirty start, policy error, verify cannot run | `AGENT_TIMEOUT`: `--agent-budget` expired and post-timeout enforcement was clean |
349
+ | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
350
+ | `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
351
+ | `init` | wired, or already wired | — | an item needs attention | — |
352
+ | no or unknown command | help printed (no command) | — | unknown command, help printed | — |
332
353
 
333
354
  ### Environment variables
334
355
 
package/dist/cli/index.js CHANGED
@@ -674,9 +674,9 @@ function git(args, cwd) {
674
674
  env: trustedGitEnv()
675
675
  });
676
676
  } catch (e) {
677
- const err2 = e;
678
- const detail = String(err2.stderr ?? "").trim();
679
- throw new Error(detail ? `git ${args[0]}: ${detail}` : err2.message ?? `git ${args[0]} failed`);
677
+ const err3 = e;
678
+ const detail = String(err3.stderr ?? "").trim();
679
+ throw new Error(detail ? `git ${args[0]}: ${detail}` : err3.message ?? `git ${args[0]} failed`);
680
680
  }
681
681
  }
682
682
  function blobAt(rev, path, cwd) {
@@ -864,11 +864,11 @@ function synthFileChange(displayPath, before, after) {
864
864
  maxBuffer: 256 * 1024 * 1024
865
865
  });
866
866
  } catch (e) {
867
- const err2 = e;
868
- if (err2.status !== 1) {
869
- throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
867
+ const err3 = e;
868
+ if (err3.status !== 1) {
869
+ throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err3.code ?? err3.message ?? String(e)}`);
870
870
  }
871
- raw = err2.stdout ? String(err2.stdout) : "";
871
+ raw = err3.stdout ? String(err3.stdout) : "";
872
872
  }
873
873
  } finally {
874
874
  rmSync(dir, { recursive: true, force: true });
@@ -1585,7 +1585,7 @@ function branchExists(name, ctx) {
1585
1585
  }
1586
1586
  function revIsHead(rev, ctx) {
1587
1587
  if (!ctx?.cwd || rev.startsWith("-")) return null;
1588
- const parse3 = (r) => {
1588
+ const parse4 = (r) => {
1589
1589
  try {
1590
1590
  return execFileSync4("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1591
1591
  cwd: ctx.cwd,
@@ -1596,8 +1596,8 @@ function revIsHead(rev, ctx) {
1596
1596
  return null;
1597
1597
  }
1598
1598
  };
1599
- const head = parse3("HEAD");
1600
- const target = parse3(rev);
1599
+ const head = parse4("HEAD");
1600
+ const target = parse4(rev);
1601
1601
  if (!head || !target) return null;
1602
1602
  return head === target;
1603
1603
  }
@@ -1651,8 +1651,8 @@ function numericOf(e) {
1651
1651
  }
1652
1652
  return void 0;
1653
1653
  }
1654
- function metricsOf(expr, resolve8) {
1655
- const obj = resolve8(expr);
1654
+ function metricsOf(expr, resolve9) {
1655
+ const obj = resolve9(expr);
1656
1656
  if (!ts2.isObjectLiteralExpression(obj)) return void 0;
1657
1657
  const out2 = { values: {}, opaque: false };
1658
1658
  let any = false;
@@ -1666,7 +1666,7 @@ function metricsOf(expr, resolve8) {
1666
1666
  const k = keyName(p.name);
1667
1667
  if (k === null || !isMetric(k)) continue;
1668
1668
  any = true;
1669
- const n = numericOf(resolve8(p.initializer));
1669
+ const n = numericOf(resolve9(p.initializer));
1670
1670
  if (n === void 0) out2.opaque = true;
1671
1671
  else out2.values[k] = n;
1672
1672
  }
@@ -1712,17 +1712,17 @@ function parseThresholds(src) {
1712
1712
  const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
1713
1713
  try {
1714
1714
  const sf = sourceOf(src);
1715
- const resolve8 = resolverFor(sf);
1715
+ const resolve9 = resolverFor(sf);
1716
1716
  const visit = (node) => {
1717
1717
  if (ts2.isPropertyAssignment(node)) {
1718
1718
  const key2 = keyName(node.name);
1719
1719
  if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
1720
1720
  res.present = true;
1721
- const init = resolve8(node.initializer);
1721
+ const init = resolve9(node.initializer);
1722
1722
  if (!ts2.isObjectLiteralExpression(init)) {
1723
1723
  res.opaque = true;
1724
1724
  } else {
1725
- const flat = metricsOf(init, resolve8);
1725
+ const flat = metricsOf(init, resolve9);
1726
1726
  if (flat) res.global = merge(res.global, flat);
1727
1727
  for (const p of init.properties) {
1728
1728
  if (ts2.isSpreadAssignment(p)) {
@@ -1736,7 +1736,7 @@ function parseThresholds(src) {
1736
1736
  res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
1737
1737
  continue;
1738
1738
  }
1739
- const m = metricsOf(p.initializer, resolve8);
1739
+ const m = metricsOf(p.initializer, resolve9);
1740
1740
  if (!m) continue;
1741
1741
  if (k === "global") res.global = merge(res.global, m);
1742
1742
  else res.paths.set(norm(k), m);
@@ -1769,18 +1769,18 @@ function parseLists(src) {
1769
1769
  const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
1770
1770
  try {
1771
1771
  const sf = sourceOf(src);
1772
- const resolve8 = resolverFor(sf);
1772
+ const resolve9 = resolverFor(sf);
1773
1773
  const visit = (node) => {
1774
1774
  if (ts2.isPropertyAssignment(node)) {
1775
1775
  const k = keyName(node.name);
1776
1776
  const vitestCoverage = ownerKey(node) === "coverage";
1777
1777
  if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
1778
- const { items, opaque } = literals(resolve8(node.initializer));
1778
+ const { items, opaque } = literals(resolve9(node.initializer));
1779
1779
  res.collectFrom = items;
1780
1780
  res.collectKey = k === "include" ? "coverage.include" : k;
1781
1781
  if (opaque) res.opaqueCollect = true;
1782
1782
  } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
1783
- const { items, opaque } = literals(resolve8(node.initializer));
1783
+ const { items, opaque } = literals(resolve9(node.initializer));
1784
1784
  res.ignore.push(...items);
1785
1785
  if (opaque) res.opaqueIgnore = true;
1786
1786
  }
@@ -7600,6 +7600,36 @@ import { execFileSync as execFileSync6 } from "node:child_process";
7600
7600
  import { createHash as createHash6 } from "node:crypto";
7601
7601
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7602
7602
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
7603
+
7604
+ // src/verifier-limits.ts
7605
+ var VERIFIER_STAGE_COUNT = 2;
7606
+ var GENERATED_CI_TIMEOUT_MINUTES = 360;
7607
+ var GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS = 60 * 60;
7608
+ function assertEnvelopeConstants() {
7609
+ if (!Number.isInteger(VERIFIER_STAGE_COUNT) || VERIFIER_STAGE_COUNT < 1) {
7610
+ throw new Error("TamperWard verifier envelope: stage count must be a positive integer");
7611
+ }
7612
+ if (!Number.isFinite(GENERATED_CI_TIMEOUT_MINUTES) || GENERATED_CI_TIMEOUT_MINUTES <= 0) {
7613
+ throw new Error("TamperWard verifier envelope: generated CI timeout must be positive");
7614
+ }
7615
+ if (!Number.isFinite(GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS) || GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS < 0) {
7616
+ throw new Error("TamperWard verifier envelope: overhead reserve cannot be negative");
7617
+ }
7618
+ if (GENERATED_CI_TIMEOUT_MINUTES * 60 <= GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS) {
7619
+ throw new Error(
7620
+ "TamperWard verifier envelope: generated CI timeout leaves no time for verifier stages"
7621
+ );
7622
+ }
7623
+ }
7624
+ assertEnvelopeConstants();
7625
+ function requiredVerifierAuthoritySeconds(stageBudgetSecs) {
7626
+ if (!Number.isFinite(stageBudgetSecs) || stageBudgetSecs <= 0) {
7627
+ throw new Error("verifier stage budget must be a positive finite number");
7628
+ }
7629
+ return VERIFIER_STAGE_COUNT * stageBudgetSecs + GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS;
7630
+ }
7631
+
7632
+ // src/cli/init.ts
7603
7633
  var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
7604
7634
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7605
7635
  function gitConfig(cwd, key2) {
@@ -7690,7 +7720,7 @@ permissions:
7690
7720
  jobs:
7691
7721
  tamperward:
7692
7722
  runs-on: ubuntu-latest
7693
- timeout-minutes: 10
7723
+ timeout-minutes: ${GENERATED_CI_TIMEOUT_MINUTES}
7694
7724
  steps:
7695
7725
  - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
7696
7726
  with:
@@ -7708,6 +7738,12 @@ jobs:
7708
7738
  # Candidate code runs later in this job. Do not leave the checkout
7709
7739
  # token in .git/config where a verifier command can read/reuse it.
7710
7740
  persist-credentials: false
7741
+ - name: Tamperward doctor (CI verifier envelope)
7742
+ # Validate the outer GitHub job budget against the TRUSTED base policy
7743
+ # before starting visible/pristine execution. Large policy budgets remain
7744
+ # valid for custom runners; this generated authority refuses if its host
7745
+ # cannot accommodate both stages plus cleanup/reporting reserve.
7746
+ run: tamperward doctor --base "\${{ github.event.pull_request.base.sha }}" --workflow .github/workflows/tamperward.yml
7711
7747
  - name: Resolve out-of-band sign-off from PR labels
7712
7748
  id: oob
7713
7749
  env:
@@ -8190,12 +8226,139 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
8190
8226
  return 0;
8191
8227
  }
8192
8228
 
8229
+ // src/cli/doctor.ts
8230
+ import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync as readdirSync3 } from "node:fs";
8231
+ import { join as join13, resolve as resolve4 } from "node:path";
8232
+ import { parse as parse3 } from "yaml";
8233
+ var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
8234
+ function err(message) {
8235
+ process.stderr.write(`tamperward doctor: ${message}
8236
+ `);
8237
+ return 2;
8238
+ }
8239
+ function policyFor(opts, cwd) {
8240
+ if (!opts.base) return loadPolicy(cwd);
8241
+ const atBase = loadPolicyAt(opts.base, cwd);
8242
+ return atBase ?? defaultPolicy();
8243
+ }
8244
+ function asMapping(value) {
8245
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
8246
+ }
8247
+ function verifyJobs(doc) {
8248
+ const root = asMapping(doc);
8249
+ const jobs = asMapping(root?.jobs);
8250
+ if (!jobs) return [];
8251
+ const found = [];
8252
+ for (const [name, raw] of Object.entries(jobs)) {
8253
+ const job = asMapping(raw);
8254
+ if (!job || !Array.isArray(job.steps)) continue;
8255
+ const hasVerify = job.steps.some((step) => {
8256
+ const item = asMapping(step);
8257
+ return typeof item?.run === "string" && VERIFY_COMMAND.test(item.run);
8258
+ });
8259
+ if (hasVerify) found.push({ name, job });
8260
+ }
8261
+ return found;
8262
+ }
8263
+ function runDoctor(opts = {}) {
8264
+ const cwd = resolve4(opts.cwd ?? process.cwd());
8265
+ let policy;
8266
+ try {
8267
+ policy = policyFor(opts, cwd);
8268
+ } catch (e) {
8269
+ return err(e instanceof PolicyError || e instanceof Error ? e.message : String(e));
8270
+ }
8271
+ if (!policy.verify?.command) {
8272
+ return err("trusted policy has no verify.command; generated CI cannot verify this repository");
8273
+ }
8274
+ const workflowRels = [];
8275
+ if (opts.workflow) {
8276
+ workflowRels.push(opts.workflow);
8277
+ } else {
8278
+ const workflowDirRel = ".github/workflows";
8279
+ const workflowDir = resolve4(cwd, workflowDirRel);
8280
+ if (!existsSync10(workflowDir)) {
8281
+ return err(`${workflowDirRel}: workflow directory does not exist`);
8282
+ }
8283
+ let entries;
8284
+ try {
8285
+ entries = readdirSync3(workflowDir).filter((name) => /\.ya?ml$/i.test(name)).sort();
8286
+ } catch (e) {
8287
+ return err(
8288
+ `${workflowDirRel}: could not enumerate workflows (${e instanceof Error ? e.message : String(e)})`
8289
+ );
8290
+ }
8291
+ for (const name of entries) workflowRels.push(join13(workflowDirRel, name));
8292
+ }
8293
+ const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
8294
+ const requiredMinutes = Math.ceil(requiredSecs / 60);
8295
+ let verifyJobCount = 0;
8296
+ for (const workflowRel of workflowRels) {
8297
+ const workflowPath = resolve4(cwd, workflowRel);
8298
+ if (!existsSync10(workflowPath)) {
8299
+ return err(`${workflowRel}: workflow does not exist`);
8300
+ }
8301
+ let doc;
8302
+ try {
8303
+ doc = parse3(readFileSync13(workflowPath, "utf8"));
8304
+ } catch (e) {
8305
+ return err(
8306
+ `${workflowRel}: workflow is not valid YAML (${e instanceof Error ? e.message : String(e)})`
8307
+ );
8308
+ }
8309
+ const jobs = verifyJobs(doc);
8310
+ verifyJobCount += jobs.length;
8311
+ for (const { name, job } of jobs) {
8312
+ const timeout = job["timeout-minutes"];
8313
+ if (timeout === void 0 || timeout === null) {
8314
+ return err(
8315
+ `${workflowRel} job "${name}": timeout-minutes is missing; requires at least ${requiredMinutes} minutes`
8316
+ );
8317
+ }
8318
+ if (typeof timeout === "string" && timeout.includes("${{")) {
8319
+ return err(
8320
+ `${workflowRel} job "${name}": timeout-minutes must be a static numeric value; requires at least ${requiredMinutes} minutes`
8321
+ );
8322
+ }
8323
+ if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
8324
+ return err(
8325
+ `${workflowRel} job "${name}": timeout-minutes must be numeric; got ${JSON.stringify(timeout)}`
8326
+ );
8327
+ }
8328
+ if (timeout <= 0) {
8329
+ return err(
8330
+ `${workflowRel} job "${name}": timeout-minutes must be positive; got ${timeout}`
8331
+ );
8332
+ }
8333
+ if (!Number.isInteger(timeout)) {
8334
+ return err(
8335
+ `${workflowRel} job "${name}": timeout-minutes must be a whole number; got ${timeout}`
8336
+ );
8337
+ }
8338
+ if (timeout < requiredMinutes) {
8339
+ return err(
8340
+ `${workflowRel} job "${name}": timeout-minutes ${timeout} is too small; trusted verify.budget ${policy.verify.budget}s requires at least ${requiredMinutes} minutes for visible + pristine + authority overhead`
8341
+ );
8342
+ }
8343
+ }
8344
+ }
8345
+ if (verifyJobCount === 0) {
8346
+ const scope = opts.workflow ?? ".github/workflows/*.yml|*.yaml";
8347
+ return err(`${scope}: no job contains a tamperward verify step`);
8348
+ }
8349
+ process.stdout.write(
8350
+ `tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
8351
+ `
8352
+ );
8353
+ return 0;
8354
+ }
8355
+
8193
8356
  // src/cli/verify.ts
8194
8357
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
8195
8358
  import { createHash as createHash10 } from "node:crypto";
8196
- import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
8359
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
8197
8360
  import { tmpdir as tmpdir2 } from "node:os";
8198
- import { dirname as dirname7, isAbsolute as isAbsolute10, join as join15, relative as relative5, resolve as resolve6, sep as sep2 } from "node:path";
8361
+ import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
8199
8362
 
8200
8363
  // src/fingerprint.ts
8201
8364
  import { execFileSync as execFileSync7 } from "node:child_process";
@@ -8226,10 +8389,10 @@ function treeFingerprint(cwd, keepIgnored) {
8226
8389
  // src/dependency-env.ts
8227
8390
  import { createHash as createHash8 } from "node:crypto";
8228
8391
  import {
8229
- existsSync as existsSync10,
8392
+ existsSync as existsSync11,
8230
8393
  lstatSync as lstatSync3,
8231
- readdirSync as readdirSync3,
8232
- readFileSync as readFileSync13,
8394
+ readdirSync as readdirSync4,
8395
+ readFileSync as readFileSync14,
8233
8396
  readlinkSync as readlinkSync2,
8234
8397
  realpathSync as realpathSync3,
8235
8398
  statSync as statSync4
@@ -8239,15 +8402,15 @@ import {
8239
8402
  delimiter,
8240
8403
  dirname as dirname5,
8241
8404
  isAbsolute as isAbsolute9,
8242
- join as join13,
8405
+ join as join14,
8243
8406
  relative as relative4,
8244
- resolve as resolve4,
8407
+ resolve as resolve5,
8245
8408
  sep
8246
8409
  } from "node:path";
8247
8410
  var Unattestable = class extends Error {
8248
8411
  };
8249
8412
  function inside(root, candidate) {
8250
- const rel = relative4(resolve4(root), resolve4(candidate));
8413
+ const rel = relative4(resolve5(root), resolve5(candidate));
8251
8414
  return rel === "" || rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute9(rel);
8252
8415
  }
8253
8416
  function modeKind(path) {
@@ -8286,7 +8449,7 @@ function fingerprintRoot(root, cwd, h) {
8286
8449
  st = statSync4(path);
8287
8450
  if (!st.isFile()) throw new Error("not a regular file");
8288
8451
  h.update(label + "\0external-file\0" + path + "\0" + String(st.mode) + "\0");
8289
- h.update(readFileSync13(path));
8452
+ h.update(readFileSync14(path));
8290
8453
  h.update("\0");
8291
8454
  } catch (e) {
8292
8455
  throw new Unattestable(
@@ -8300,14 +8463,14 @@ function fingerprintRoot(root, cwd, h) {
8300
8463
  visited.add(realDir);
8301
8464
  let entries;
8302
8465
  try {
8303
- entries = readdirSync3(dir, { withFileTypes: true });
8466
+ entries = readdirSync4(dir, { withFileTypes: true });
8304
8467
  } catch (e) {
8305
8468
  throw new Unattestable(
8306
8469
  "dependency directory " + JSON.stringify(dir) + " cannot be read (" + (e instanceof Error ? e.message : String(e)) + ")"
8307
8470
  );
8308
8471
  }
8309
8472
  for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
8310
- const path = join13(dir, entry.name);
8473
+ const path = join14(dir, entry.name);
8311
8474
  const rel = namespace ? namespace + "/" + entry.name : entry.name;
8312
8475
  let ident;
8313
8476
  try {
@@ -8320,7 +8483,7 @@ function fingerprintRoot(root, cwd, h) {
8320
8483
  h.update(rel + "\0" + ident.kind + "\0" + String(ident.mode) + "\0");
8321
8484
  if (ident.kind === "file") {
8322
8485
  try {
8323
- h.update(readFileSync13(path));
8486
+ h.update(readFileSync14(path));
8324
8487
  h.update("\0");
8325
8488
  } catch (e) {
8326
8489
  throw new Unattestable(
@@ -8379,7 +8542,7 @@ function snapshot(roots, probes, cwd) {
8379
8542
  const h = createHash8("sha256");
8380
8543
  for (const probe of probes) {
8381
8544
  h.update(
8382
- "probe\0" + probe.kind + "\0" + probe.path + "\0" + (existsSync10(probe.path) ? "present" : "absent") + "\0"
8545
+ "probe\0" + probe.kind + "\0" + probe.path + "\0" + (existsSync11(probe.path) ? "present" : "absent") + "\0"
8383
8546
  );
8384
8547
  }
8385
8548
  for (const root of roots) fingerprintRoot(root, cwd, h);
@@ -8390,7 +8553,7 @@ function freezeRoot(kind, path) {
8390
8553
  if (!statSync4(path).isDirectory()) {
8391
8554
  throw new Error("not a directory");
8392
8555
  }
8393
- return { kind, path: resolve4(path), realPath: realpathSync3(path) };
8556
+ return { kind, path: resolve5(path), realPath: realpathSync3(path) };
8394
8557
  } catch (e) {
8395
8558
  throw new Unattestable(
8396
8559
  kind + " root " + JSON.stringify(path) + " is not resolvable (" + (e instanceof Error ? e.message : String(e)) + ")"
@@ -8426,17 +8589,17 @@ function firstExecutable(command) {
8426
8589
  function resolveExecutable(token, cwd, env) {
8427
8590
  if (!token) return null;
8428
8591
  if (token.includes("/") || token.includes("\\")) {
8429
- const path = isAbsolute9(token) ? token : resolve4(cwd, token);
8592
+ const path = isAbsolute9(token) ? token : resolve5(cwd, token);
8430
8593
  try {
8431
- return existsSync10(path) ? realpathSync3(path) : null;
8594
+ return existsSync11(path) ? realpathSync3(path) : null;
8432
8595
  } catch {
8433
8596
  return null;
8434
8597
  }
8435
8598
  }
8436
8599
  for (const entry of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
8437
- const candidate = resolve4(entry, token);
8600
+ const candidate = resolve5(entry, token);
8438
8601
  try {
8439
- if (existsSync10(candidate) && statSync4(candidate).isFile()) return realpathSync3(candidate);
8602
+ if (existsSync11(candidate) && statSync4(candidate).isFile()) return realpathSync3(candidate);
8440
8603
  } catch {
8441
8604
  }
8442
8605
  }
@@ -8453,13 +8616,13 @@ function looksPython(command, executable, token) {
8453
8616
  }
8454
8617
  function inferredVenvFromToken(token, cwd) {
8455
8618
  if (!token || !token.includes("/") && !token.includes("\\")) return null;
8456
- const path = isAbsolute9(token) ? token : resolve4(cwd, token);
8619
+ const path = isAbsolute9(token) ? token : resolve5(cwd, token);
8457
8620
  if (!pythonName(basename3(path))) return null;
8458
8621
  const parent = dirname5(path);
8459
8622
  const parentName = basename3(parent).toLowerCase();
8460
8623
  if (parentName !== "bin" && parentName !== "scripts") return null;
8461
8624
  const root = dirname5(parent);
8462
- return existsSync10(join13(root, "pyvenv.cfg")) ? root : null;
8625
+ return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
8463
8626
  }
8464
8627
  function unsupportedEcosystem(command, token) {
8465
8628
  const name = token ? basename3(token).toLowerCase() : "";
@@ -8475,13 +8638,13 @@ function inferredVenv(executable) {
8475
8638
  const parentName = basename3(parent).toLowerCase();
8476
8639
  if (parentName !== "bin" && parentName !== "scripts") return null;
8477
8640
  const root = dirname5(parent);
8478
- return existsSync10(join13(root, "pyvenv.cfg")) ? root : null;
8641
+ return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
8479
8642
  }
8480
8643
  function venvProblem(root) {
8481
- const cfg = join13(root, "pyvenv.cfg");
8482
- if (!existsSync10(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
8644
+ const cfg = join14(root, "pyvenv.cfg");
8645
+ if (!existsSync11(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
8483
8646
  try {
8484
- const body = readFileSync13(cfg, "utf8");
8647
+ const body = readFileSync14(cfg, "utf8");
8485
8648
  if (/^\s*include-system-site-packages\s*=\s*true\s*$/im.test(body)) {
8486
8649
  return "Python environment " + JSON.stringify(root) + " enables system site-packages; those external package roots are not bounded";
8487
8650
  }
@@ -8491,7 +8654,7 @@ function venvProblem(root) {
8491
8654
  return null;
8492
8655
  }
8493
8656
  function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
8494
- const cwd = resolve4(cwdInput);
8657
+ const cwd = resolve5(cwdInput);
8495
8658
  const roots = [];
8496
8659
  const probes = [];
8497
8660
  const seen = /* @__PURE__ */ new Set();
@@ -8504,8 +8667,8 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
8504
8667
  }
8505
8668
  };
8506
8669
  try {
8507
- const nodeModules = join13(cwd, "node_modules");
8508
- if (existsSync10(nodeModules)) addRoot("node_modules", nodeModules);
8670
+ const nodeModules = join14(cwd, "node_modules");
8671
+ if (existsSync11(nodeModules)) addRoot("node_modules", nodeModules);
8509
8672
  else probes.push({ kind: "node_modules", path: nodeModules });
8510
8673
  const token = firstExecutable(command);
8511
8674
  const executable = resolveExecutable(token, cwd, env);
@@ -8514,11 +8677,11 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
8514
8677
  if (!isAbsolute9(env.VIRTUAL_ENV)) {
8515
8678
  throw new Unattestable("VIRTUAL_ENV is relative (" + JSON.stringify(env.VIRTUAL_ENV) + ")");
8516
8679
  }
8517
- venv = resolve4(env.VIRTUAL_ENV);
8680
+ venv = resolve5(env.VIRTUAL_ENV);
8518
8681
  } else {
8519
8682
  venv = inferredVenv(executable) ?? inferredVenvFromToken(token, cwd);
8520
- if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) && existsSync10(join13(cwd, ".venv", "pyvenv.cfg"))) {
8521
- venv = join13(cwd, ".venv");
8683
+ if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) && existsSync11(join14(cwd, ".venv", "pyvenv.cfg"))) {
8684
+ venv = join14(cwd, ".venv");
8522
8685
  }
8523
8686
  }
8524
8687
  if (venv) {
@@ -8550,7 +8713,7 @@ function checkDependencyEnvironment(cwdInput, descriptor) {
8550
8713
  return { ok: false, reason: descriptor.reason ?? "dependency environment is not attestable" };
8551
8714
  }
8552
8715
  try {
8553
- const fingerprint2 = snapshot(descriptor.roots, descriptor.probes, resolve4(cwdInput));
8716
+ const fingerprint2 = snapshot(descriptor.roots, descriptor.probes, resolve5(cwdInput));
8554
8717
  if (fingerprint2 !== descriptor.fingerprint) {
8555
8718
  return { ok: false, fingerprint: fingerprint2, reason: "dependency environment fingerprint changed" };
8556
8719
  }
@@ -8579,8 +8742,8 @@ function dependencyEnvironmentSummary(descriptor) {
8579
8742
  // src/verifier-backend.ts
8580
8743
  import { createHash as createHash9, randomUUID } from "node:crypto";
8581
8744
  import { spawnSync } from "node:child_process";
8582
- import { accessSync, constants as constants2, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
8583
- import { delimiter as delimiter2, dirname as dirname6, join as join14, resolve as resolve5 } from "node:path";
8745
+ import { accessSync, constants as constants2, readFileSync as readFileSync15, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
8746
+ import { delimiter as delimiter2, dirname as dirname6, join as join15, resolve as resolve6 } from "node:path";
8584
8747
  var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
8585
8748
  memoryBytes: 2 * 1024 * 1024 * 1024,
8586
8749
  // Docker interprets memory-swap == memory as no additional swap allowance.
@@ -8609,7 +8772,7 @@ function writableByCaller(path) {
8609
8772
  }
8610
8773
  }
8611
8774
  function replaceableByCaller(path) {
8612
- let current = resolve5(path);
8775
+ let current = resolve6(path);
8613
8776
  if (writableByCaller(current)) return true;
8614
8777
  current = dirname6(current);
8615
8778
  while (true) {
@@ -8623,7 +8786,7 @@ function replaceableByCaller(path) {
8623
8786
  function findExecutable(name) {
8624
8787
  for (const raw of (process.env.PATH ?? "").split(delimiter2)) {
8625
8788
  if (!raw) continue;
8626
- const candidate = join14(raw, name);
8789
+ const candidate = join15(raw, name);
8627
8790
  try {
8628
8791
  accessSync(candidate, constants2.X_OK);
8629
8792
  return realpathSync4(candidate);
@@ -8633,7 +8796,7 @@ function findExecutable(name) {
8633
8796
  return null;
8634
8797
  }
8635
8798
  function fileSha256(path) {
8636
- return createHash9("sha256").update(readFileSync14(path)).digest("hex");
8799
+ return createHash9("sha256").update(readFileSync15(path)).digest("hex");
8637
8800
  }
8638
8801
  function engineClientEnv() {
8639
8802
  return {
@@ -8955,7 +9118,7 @@ function runContainerStage(backend, dir, command, budgetSecs) {
8955
9118
  const args = containerRunArgs({
8956
9119
  image: backend.image,
8957
9120
  name,
8958
- workspace: resolve5(dir),
9121
+ workspace: resolve6(dir),
8959
9122
  command,
8960
9123
  uid,
8961
9124
  gid,
@@ -9124,7 +9287,7 @@ function baseIsAncestorOfHead(base, cwd) {
9124
9287
  return r.status === 0;
9125
9288
  }
9126
9289
  function inside2(root, path) {
9127
- const rel = relative5(resolve6(root), resolve6(path));
9290
+ const rel = relative5(resolve7(root), resolve7(path));
9128
9291
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute10(rel);
9129
9292
  }
9130
9293
  function linkParts(path) {
@@ -9141,7 +9304,7 @@ function rootedLinkTarget(target) {
9141
9304
  return isAbsolute10(target) || driveRelativeLinkTarget(target);
9142
9305
  }
9143
9306
  function safeSymlink(target, out2, root, label) {
9144
- if (rootedLinkTarget(target) || !inside2(root, resolve6(dirname7(out2), target))) {
9307
+ if (rootedLinkTarget(target) || !inside2(root, resolve7(dirname7(out2), target))) {
9145
9308
  throw linkEscape(label, target);
9146
9309
  }
9147
9310
  rmSync3(out2, { force: true });
@@ -9167,7 +9330,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
9167
9330
  continue;
9168
9331
  }
9169
9332
  const base = domain === "tree" ? root : dependencyRoot;
9170
- const current = join15(base, ...stack);
9333
+ const current = join16(base, ...stack);
9171
9334
  let st;
9172
9335
  try {
9173
9336
  st = lstatSync4(current);
@@ -9193,7 +9356,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
9193
9356
  throw linkEscape(label, target);
9194
9357
  }
9195
9358
  stack = [];
9196
- pending.unshift(...linkParts(relative5(dependencyRoot, resolve6(next))));
9359
+ pending.unshift(...linkParts(relative5(dependencyRoot, resolve7(next))));
9197
9360
  } else {
9198
9361
  pending.unshift(...linkParts(next));
9199
9362
  }
@@ -9203,7 +9366,7 @@ function rejectLinkedParent(cwd, rel) {
9203
9366
  const parts = rel.split("/").slice(0, -1);
9204
9367
  let current = cwd;
9205
9368
  for (const part of parts) {
9206
- current = join15(current, part);
9369
+ current = join16(current, part);
9207
9370
  let st;
9208
9371
  try {
9209
9372
  st = lstatSync4(current);
@@ -9219,7 +9382,7 @@ function materialize(cwd, dest, dependencyRoot) {
9219
9382
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
9220
9383
  const links = [];
9221
9384
  for (const rel of listed) {
9222
- const src = join15(cwd, rel);
9385
+ const src = join16(cwd, rel);
9223
9386
  rejectLinkedParent(cwd, rel);
9224
9387
  let st;
9225
9388
  try {
@@ -9227,7 +9390,7 @@ function materialize(cwd, dest, dependencyRoot) {
9227
9390
  } catch {
9228
9391
  continue;
9229
9392
  }
9230
- const out2 = join15(dest, rel);
9393
+ const out2 = join16(dest, rel);
9231
9394
  mkdirSync6(dirname7(out2), { recursive: true });
9232
9395
  dropSymlink(out2);
9233
9396
  if (st.isSymbolicLink()) {
@@ -9244,7 +9407,7 @@ function materialize(cwd, dest, dependencyRoot) {
9244
9407
  for (const link of links) {
9245
9408
  validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, link.rel);
9246
9409
  }
9247
- if (dependencyRoot) symlinkSync(dependencyRoot, join15(dest, "node_modules"), "dir");
9410
+ if (dependencyRoot) symlinkSync(dependencyRoot, join16(dest, "node_modules"), "dir");
9248
9411
  }
9249
9412
  function dropSymlink(p) {
9250
9413
  try {
@@ -9277,11 +9440,11 @@ function overlayDigest(dest, paths) {
9277
9440
  for (const rel of [...paths].sort()) {
9278
9441
  h.update(rel);
9279
9442
  h.update("\0");
9280
- const p = join15(dest, rel);
9443
+ const p = join16(dest, rel);
9281
9444
  try {
9282
9445
  const st = lstatSync4(p);
9283
9446
  h.update(String(st.mode));
9284
- h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync15(p));
9447
+ h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync16(p));
9285
9448
  } catch {
9286
9449
  h.update("<gone>");
9287
9450
  }
@@ -9305,7 +9468,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
9305
9468
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
9306
9469
  }
9307
9470
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
9308
- const out2 = join15(dest, e.path);
9471
+ const out2 = join16(dest, e.path);
9309
9472
  mkdirSync6(dirname7(out2), { recursive: true });
9310
9473
  rmSync3(out2, { force: true });
9311
9474
  if (e.mode === "120000") {
@@ -9322,7 +9485,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
9322
9485
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
9323
9486
  for (const rel of inCopy) {
9324
9487
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
9325
- rmSync3(join15(dest, rel), { force: true });
9488
+ rmSync3(join16(dest, rel), { force: true });
9326
9489
  removed++;
9327
9490
  }
9328
9491
  for (const link of restoredLinks) {
@@ -9367,8 +9530,8 @@ function suiteEnv(scratch) {
9367
9530
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
9368
9531
  env[k] = v;
9369
9532
  }
9370
- const userRc = join15(scratch, "npmrc-user");
9371
- const globalRc = join15(scratch, "npmrc-global");
9533
+ const userRc = join16(scratch, "npmrc-user");
9534
+ const globalRc = join16(scratch, "npmrc-global");
9372
9535
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
9373
9536
  env.npm_config_userconfig = userRc;
9374
9537
  env.npm_config_globalconfig = globalRc;
@@ -9378,8 +9541,8 @@ function suiteEnv(scratch) {
9378
9541
  }
9379
9542
  function runLocalSuite(dir, cmd, budgetSecs) {
9380
9543
  const t0 = Date.now();
9381
- const outDir = mkdtempSync2(join15(tmpdir2(), "tw-verify-run-"));
9382
- const outFile = join15(outDir, "result.json");
9544
+ const outDir = mkdtempSync2(join16(tmpdir2(), "tw-verify-run-"));
9545
+ const outFile = join16(outDir, "result.json");
9383
9546
  try {
9384
9547
  spawnSync2(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
9385
9548
  cwd: dir,
@@ -9392,7 +9555,7 @@ function runLocalSuite(dir, cmd, budgetSecs) {
9392
9555
  const secs = Math.round((Date.now() - t0) / 1e3);
9393
9556
  let r;
9394
9557
  try {
9395
- r = JSON.parse(readFileSync15(outFile, "utf8"));
9558
+ r = JSON.parse(readFileSync16(outFile, "utf8"));
9396
9559
  } catch {
9397
9560
  return { exit: null, secs };
9398
9561
  }
@@ -9484,8 +9647,8 @@ function runVerify(opts) {
9484
9647
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
9485
9648
  return 2;
9486
9649
  }
9487
- const visRoot = mkdtempSync2(join15(tmpdir2(), "tw-verify-vis-"));
9488
- const visDir = join15(visRoot, "t");
9650
+ const visRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-vis-"));
9651
+ const visDir = join16(visRoot, "t");
9489
9652
  const cleanup = (dirs) => {
9490
9653
  if (opts.keep) return;
9491
9654
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -9542,8 +9705,8 @@ function runVerify(opts) {
9542
9705
  out2((dependencyAfterVisible.reason ?? "dependency identity changed") + ". Failing closed, not open.");
9543
9706
  return 2;
9544
9707
  }
9545
- const priRoot = mkdtempSync2(join15(tmpdir2(), "tw-verify-pri-"));
9546
- const priDir = join15(priRoot, "t");
9708
+ const priRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-pri-"));
9709
+ const priDir = join16(priRoot, "t");
9547
9710
  let restored = [];
9548
9711
  let removedAdded = 0;
9549
9712
  try {
@@ -9694,16 +9857,233 @@ function parseVerify(args) {
9694
9857
 
9695
9858
  // src/cli/run.ts
9696
9859
  import { execFileSync as execFileSync9, spawnSync as spawnSync3 } from "node:child_process";
9697
- import { readdirSync as readdirSync4, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6 } from "node:fs";
9698
- import { resolve as resolve7 } from "node:path";
9860
+ import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync17, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
9861
+ import { tmpdir as tmpdir3 } from "node:os";
9862
+ import { join as join17, resolve as resolve8 } from "node:path";
9699
9863
  var out = (s) => process.stdout.write(s + "\n");
9700
- var err = (s) => process.stderr.write(s + "\n");
9864
+ var err2 = (s) => process.stderr.write(s + "\n");
9865
+ var AGENT_SUPERVISOR = String.raw`
9866
+ const { spawn, spawnSync } = require('node:child_process');
9867
+ const fs = require('node:fs');
9868
+
9869
+ const [resultFile, budgetRaw, command, ...args] = process.argv.slice(1);
9870
+ const budgetMs = Number(budgetRaw);
9871
+ let child;
9872
+ let timedOut = false;
9873
+ let finished = false;
9874
+
9875
+ function writeResult(value) {
9876
+ try { fs.writeFileSync(resultFile, JSON.stringify(value)); } catch {}
9877
+ }
9878
+
9879
+ function linuxDescendants(rootPid) {
9880
+ if (process.platform !== 'linux') return [];
9881
+ const byParent = new Map();
9882
+ let names = [];
9883
+ try { names = fs.readdirSync('/proc').filter((x) => /^\d+$/.test(x)); } catch { return []; }
9884
+ for (const name of names) {
9885
+ const pid = Number(name);
9886
+ try {
9887
+ const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');
9888
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
9889
+ const ppid = Number(fields[1]);
9890
+ if (!Number.isFinite(ppid)) continue;
9891
+ const kids = byParent.get(ppid) || [];
9892
+ kids.push(pid);
9893
+ byParent.set(ppid, kids);
9894
+ } catch {}
9895
+ }
9896
+ const out = [];
9897
+ const seen = new Set([rootPid]);
9898
+ const stack = [rootPid];
9899
+ while (stack.length) {
9900
+ const parent = stack.pop();
9901
+ for (const childPid of (byParent.get(parent) || [])) {
9902
+ if (seen.has(childPid)) continue;
9903
+ seen.add(childPid);
9904
+ out.push(childPid);
9905
+ stack.push(childPid);
9906
+ }
9907
+ }
9908
+ return out;
9909
+ }
9910
+
9911
+ function killTree(pid) {
9912
+ if (!pid) return;
9913
+ if (process.platform === 'win32') {
9914
+ try { spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
9915
+ return;
9916
+ }
9917
+
9918
+ // Snapshot Linux descendants BEFORE killing the process-group leader. A
9919
+ // descendant may have called setsid() and escaped the group while remaining
9920
+ // in the agent's parent/child tree; /proc still lets us identify it.
9921
+ const descendants = linuxDescendants(pid);
9922
+ try { process.kill(-pid, 'SIGKILL'); } catch {
9923
+ try { process.kill(pid, 'SIGKILL'); } catch {}
9924
+ }
9925
+ for (const childPid of descendants.reverse()) {
9926
+ try { process.kill(childPid, 'SIGKILL'); } catch {}
9927
+ }
9928
+ }
9929
+
9930
+ try {
9931
+ child = spawn(command, args, {
9932
+ stdio: 'inherit',
9933
+ detached: process.platform !== 'win32',
9934
+ });
9935
+ } catch (e) {
9936
+ writeResult({ exit: 1, timedOut: false, failure: String(e) });
9937
+ process.exit(0);
9938
+ }
9939
+
9940
+ writeResult({ pid: child.pid, started: true });
9941
+
9942
+ const timer = setTimeout(() => {
9943
+ timedOut = true;
9944
+ killTree(child.pid);
9945
+ }, budgetMs);
9946
+ timer.unref();
9947
+
9948
+ child.once('error', (e) => {
9949
+ if (finished) return;
9950
+ finished = true;
9951
+ clearTimeout(timer);
9952
+ writeResult({ exit: 1, timedOut, failure: String(e) });
9953
+ process.exit(0);
9954
+ });
9955
+
9956
+ child.once('exit', (code, signal) => {
9957
+ if (finished) return;
9958
+ finished = true;
9959
+ clearTimeout(timer);
9960
+ // A timeout owns the public status even when the killed process reports a
9961
+ // shell-specific signal/exit value. The envelope will still adjudicate.
9962
+ writeResult({
9963
+ exit: timedOut ? 124 : (code == null ? 1 : code),
9964
+ timedOut,
9965
+ signal: signal == null ? null : String(signal),
9966
+ });
9967
+ process.exit(0);
9968
+ });
9969
+ `;
9970
+ function linuxDescendantPids(rootPid) {
9971
+ if (process.platform !== "linux") return [];
9972
+ const byParent = /* @__PURE__ */ new Map();
9973
+ let names;
9974
+ try {
9975
+ names = readdirSync5("/proc").filter((x) => /^\d+$/.test(x));
9976
+ } catch {
9977
+ return [];
9978
+ }
9979
+ for (const name of names) {
9980
+ const pid = Number(name);
9981
+ try {
9982
+ const stat = readFileSync17(`/proc/${name}/stat`, "utf8");
9983
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
9984
+ const ppid = Number(fields[1]);
9985
+ if (!Number.isFinite(ppid)) continue;
9986
+ const kids = byParent.get(ppid) ?? [];
9987
+ kids.push(pid);
9988
+ byParent.set(ppid, kids);
9989
+ } catch {
9990
+ }
9991
+ }
9992
+ const out2 = [];
9993
+ const seen = /* @__PURE__ */ new Set([rootPid]);
9994
+ const stack = [rootPid];
9995
+ while (stack.length) {
9996
+ const parent = stack.pop();
9997
+ for (const childPid of byParent.get(parent) ?? []) {
9998
+ if (seen.has(childPid)) continue;
9999
+ seen.add(childPid);
10000
+ out2.push(childPid);
10001
+ stack.push(childPid);
10002
+ }
10003
+ }
10004
+ return out2;
10005
+ }
10006
+ function killAgentTree(pid) {
10007
+ if (!Number.isInteger(pid) || pid <= 1) return;
10008
+ if (process.platform === "win32") {
10009
+ try {
10010
+ spawnSync3("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5e3 });
10011
+ } catch {
10012
+ }
10013
+ return;
10014
+ }
10015
+ const descendants = linuxDescendantPids(pid);
10016
+ try {
10017
+ process.kill(-pid, "SIGKILL");
10018
+ } catch {
10019
+ try {
10020
+ process.kill(pid, "SIGKILL");
10021
+ } catch {
10022
+ }
10023
+ }
10024
+ for (const childPid of descendants.reverse()) {
10025
+ try {
10026
+ process.kill(childPid, "SIGKILL");
10027
+ } catch {
10028
+ }
10029
+ }
10030
+ }
10031
+ function runAgentWithBudget(argv2, cwd, budgetSecs) {
10032
+ const stateDir = mkdtempSync3(join17(tmpdir3(), "tw-agent-supervisor-"));
10033
+ const resultFile = join17(stateDir, "result.json");
10034
+ let supervisorTimedOut = false;
10035
+ try {
10036
+ const supervisor = spawnSync3(
10037
+ process.execPath,
10038
+ ["-e", AGENT_SUPERVISOR, resultFile, String(budgetSecs * 1e3), ...argv2],
10039
+ {
10040
+ cwd,
10041
+ stdio: "inherit",
10042
+ // Backstop only. The inner supervisor owns the intended budget and
10043
+ // process-tree kill. This prevents a broken supervisor hanging run.
10044
+ timeout: Math.ceil(budgetSecs * 1e3) + 1e4,
10045
+ killSignal: "SIGKILL"
10046
+ }
10047
+ );
10048
+ supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
10049
+ let state = {};
10050
+ try {
10051
+ state = JSON.parse(readFileSync17(resultFile, "utf8"));
10052
+ } catch {
10053
+ }
10054
+ if (supervisorTimedOut || state.timedOut) {
10055
+ if (state.pid) killAgentTree(state.pid);
10056
+ return {
10057
+ exit: 124,
10058
+ timedOut: true,
10059
+ signal: state.signal ?? null,
10060
+ ...state.failure ? { failure: state.failure } : {}
10061
+ };
10062
+ }
10063
+ if (typeof state.exit === "number") {
10064
+ return {
10065
+ exit: state.exit,
10066
+ timedOut: false,
10067
+ signal: state.signal ?? null,
10068
+ ...state.failure ? { failure: state.failure } : {}
10069
+ };
10070
+ }
10071
+ if (state.pid) killAgentTree(state.pid);
10072
+ return {
10073
+ exit: 1,
10074
+ timedOut: false,
10075
+ failure: state.failure ?? "agent supervisor did not produce a final status"
10076
+ };
10077
+ } finally {
10078
+ rmSync4(stateDir, { recursive: true, force: true });
10079
+ }
10080
+ }
9701
10081
  function git3(args, cwd) {
9702
10082
  return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
9703
10083
  }
9704
10084
  function nowTicks() {
9705
10085
  try {
9706
- return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
10086
+ return Math.floor(parseFloat(readFileSync17("/proc/uptime", "utf8").split(" ")[0]) * 100);
9707
10087
  } catch {
9708
10088
  return Number.POSITIVE_INFINITY;
9709
10089
  }
@@ -9718,7 +10098,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
9718
10098
  }
9719
10099
  let pids;
9720
10100
  try {
9721
- pids = readdirSync4("/proc").filter((d) => /^\d+$/.test(d));
10101
+ pids = readdirSync5("/proc").filter((d) => /^\d+$/.test(d));
9722
10102
  } catch {
9723
10103
  return out2;
9724
10104
  }
@@ -9726,7 +10106,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
9726
10106
  const n = Number(pid);
9727
10107
  if (n === process.pid) continue;
9728
10108
  try {
9729
- const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
10109
+ const stat = readFileSync17(`/proc/${pid}/stat`, "utf8");
9730
10110
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
9731
10111
  if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
9732
10112
  } catch {
@@ -9734,14 +10114,14 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
9734
10114
  }
9735
10115
  const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
9736
10116
  try {
9737
- for (const fd of readdirSync4(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
10117
+ for (const fd of readdirSync5(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
9738
10118
  } catch {
9739
10119
  }
9740
10120
  let holds = false;
9741
10121
  for (const link of procLinks) {
9742
10122
  try {
9743
10123
  const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
9744
- const held = raw.startsWith("/") ? resolve7(raw) : raw;
10124
+ const held = raw.startsWith("/") ? resolve8(raw) : raw;
9745
10125
  if (held === real || held.startsWith(real + "/")) {
9746
10126
  holds = true;
9747
10127
  break;
@@ -9754,16 +10134,22 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
9754
10134
  return out2;
9755
10135
  }
9756
10136
  function runEnvelope(opts) {
9757
- const cwd = resolve7(opts.cwd ?? process.cwd());
10137
+ const cwd = resolve8(opts.cwd ?? process.cwd());
9758
10138
  if (!opts.argv.length) {
9759
- err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
10139
+ err2("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
10140
+ return 2;
10141
+ }
10142
+ if (opts.agentBudget !== void 0 && (!Number.isFinite(opts.agentBudget) || opts.agentBudget <= 0)) {
10143
+ err2(
10144
+ `tamperward run: --agent-budget needs a positive number of seconds (got ${JSON.stringify(opts.agentBudget)}) \u2014 failing closed.`
10145
+ );
9760
10146
  return 2;
9761
10147
  }
9762
10148
  let base;
9763
10149
  try {
9764
10150
  base = git3(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
9765
10151
  } catch {
9766
- err(`tamperward run: cannot resolve trusted base ${opts.base ?? "HEAD"} \u2014 failing closed.`);
10152
+ err2(`tamperward run: cannot resolve trusted base ${opts.base ?? "HEAD"} \u2014 failing closed.`);
9767
10153
  return 2;
9768
10154
  }
9769
10155
  let frozenPolicy;
@@ -9774,19 +10160,19 @@ function runEnvelope(opts) {
9774
10160
  frozenCmd = opts.cmd ?? frozenPolicy.verify?.command;
9775
10161
  frozenBudget = opts.budget ?? frozenPolicy.verify?.budget;
9776
10162
  } catch (e) {
9777
- err(`tamperward run: cannot load the entry-time policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed.`);
10163
+ err2(`tamperward run: cannot load the entry-time policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed.`);
9778
10164
  return 2;
9779
10165
  }
9780
10166
  if (!frozenCmd) {
9781
- err("tamperward run: no suite command at entry (policy `verify: { command: ... }` or --cmd) \u2014");
9782
- err("refusing to start: a verifier that appears after the agent runs would be agent-authored.");
10167
+ err2("tamperward run: no suite command at entry (policy `verify: { command: ... }` or --cmd) \u2014");
10168
+ err2("refusing to start: a verifier that appears after the agent runs would be agent-authored.");
9783
10169
  return 2;
9784
10170
  }
9785
10171
  if (!opts.allowDirty) {
9786
10172
  const dirty = git3(["status", "--porcelain"], cwd).trim();
9787
10173
  if (dirty) {
9788
- err("tamperward run: working tree is dirty before the agent starts \u2014 the envelope cannot");
9789
- err("attribute changes. Commit or stash first, or pass --allow-dirty to own the risk.");
10174
+ err2("tamperward run: working tree is dirty before the agent starts \u2014 the envelope cannot");
10175
+ err2("attribute changes. Commit or stash first, or pass --allow-dirty to own the risk.");
9790
10176
  return 2;
9791
10177
  }
9792
10178
  }
@@ -9794,25 +10180,25 @@ function runEnvelope(opts) {
9794
10180
  out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
9795
10181
  out(`tamperward run \u2014 verifier backend: ${verifierBackendSummary(verifierBackend)}`);
9796
10182
  if (!verifierBackend.available) {
9797
- err("tamperward run: the requested verifier backend cannot be established \u2014");
9798
- err(`${verifierBackend.reason ?? "unknown backend failure"}. Failing closed before the agent starts.`);
10183
+ err2("tamperward run: the requested verifier backend cannot be established \u2014");
10184
+ err2(`${verifierBackend.reason ?? "unknown backend failure"}. Failing closed before the agent starts.`);
9799
10185
  return 2;
9800
10186
  }
9801
10187
  if (verifierBackend.kind === "container") {
9802
- err("tamperward run: container verification requires a frozen candidate handoff from an");
9803
- err("agent domain that cannot control the verifier engine. `run` executes the agent under");
9804
- err("this host identity, so it cannot honestly provide that separation. Failing closed");
9805
- err("before the agent starts; use standalone `tamperward verify` in trusted CI or after");
9806
- err("an externally isolated agent produces the frozen candidate artifact.");
10188
+ err2("tamperward run: container verification requires a frozen candidate handoff from an");
10189
+ err2("agent domain that cannot control the verifier engine. `run` executes the agent under");
10190
+ err2("this host identity, so it cannot honestly provide that separation. Failing closed");
10191
+ err2("before the agent starts; use standalone `tamperward verify` in trusted CI or after");
10192
+ err2("an externally isolated agent produces the frozen candidate artifact.");
9807
10193
  return 2;
9808
10194
  }
9809
10195
  const dependencyEnvironment = verifierBackend.kind === "local" ? discoverDependencyEnvironment(cwd, frozenCmd) : null;
9810
10196
  if (dependencyEnvironment) {
9811
10197
  out(`tamperward run \u2014 dependency environment: ${dependencyEnvironmentSummary(dependencyEnvironment)}`);
9812
10198
  if (dependencyEnvironment.status === "unattestable" && !opts.allowDepDrift) {
9813
- err("tamperward run: the verifier dependency environment cannot be attested \u2014");
9814
- err(`${dependencyEnvironment.reason ?? "unknown dependency environment"}. Failing closed before the agent starts.`);
9815
- err("(Pass --allow-dep-drift only if you explicitly own this dependency-integrity risk.)");
10199
+ err2("tamperward run: the verifier dependency environment cannot be attested \u2014");
10200
+ err2(`${dependencyEnvironment.reason ?? "unknown dependency environment"}. Failing closed before the agent starts.`);
10201
+ err2("(Pass --allow-dep-drift only if you explicitly own this dependency-integrity risk.)");
9816
10202
  return 2;
9817
10203
  }
9818
10204
  } else {
@@ -9820,32 +10206,49 @@ function runEnvelope(opts) {
9820
10206
  }
9821
10207
  const rewritesBefore = objectRewriteState(cwd);
9822
10208
  const spawnTicks = nowTicks();
9823
- const agent = spawnSync3(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
9824
- const agentExit = agent.status ?? 1;
10209
+ const agentRun = opts.agentBudget === void 0 ? (() => {
10210
+ const agent = spawnSync3(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
10211
+ return {
10212
+ exit: agent.status ?? 1,
10213
+ timedOut: false,
10214
+ signal: agent.signal ? String(agent.signal) : null,
10215
+ ...agent.error ? { failure: agent.error.message } : {}
10216
+ };
10217
+ })() : runAgentWithBudget(opts.argv, cwd, opts.agentBudget);
10218
+ const agentExit = agentRun.exit;
10219
+ const agentTimedOut = agentRun.timedOut;
10220
+ if (agentRun.failure && !agentTimedOut) {
10221
+ err2(`tamperward run: agent runtime failed to start/report cleanly (${agentRun.failure}) \u2014 continuing to adjudicate the released tree.`);
10222
+ }
10223
+ if (agentTimedOut) {
10224
+ err2(
10225
+ `tamperward run: AGENT_TIMEOUT \u2014 wrapped agent exceeded ${opts.agentBudget}s; its owned process tree was terminated. Post-timeout adjudication continues.`
10226
+ );
10227
+ }
9825
10228
  if (objectRewriteState(cwd) !== rewritesBefore) {
9826
- err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
9827
- err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
9828
- err("read against the anchor we started from. That is the finding.");
10229
+ err2("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
10230
+ err2("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
10231
+ err2("read against the anchor we started from. That is the finding.");
9829
10232
  out(`
9830
10233
  tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
9831
10234
  return 1;
9832
10235
  }
9833
10236
  const dependencyBeforeAdjudication = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
9834
10237
  if (!dependencyBeforeAdjudication.ok) {
9835
- err("tamperward run: the frozen dependency environment changed before adjudication began \u2014");
9836
- err(`${dependencyBeforeAdjudication.reason ?? "dependency identity changed"}.`);
10238
+ err2("tamperward run: the frozen dependency environment changed before adjudication began \u2014");
10239
+ err2(`${dependencyBeforeAdjudication.reason ?? "dependency identity changed"}.`);
9837
10240
  if (!opts.allowDepDrift) {
9838
10241
  out(`
9839
10242
  tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
9840
10243
  return 1;
9841
10244
  }
9842
- err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
10245
+ err2("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
9843
10246
  }
9844
10247
  const head = git3(["rev-parse", "HEAD"], cwd).trim();
9845
10248
  const isAncestor = spawnSync3("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
9846
10249
  if (isAncestor.status !== 0) {
9847
- err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
9848
- err("the agent rewrote history out from under the anchor. That is the finding.");
10250
+ err2(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
10251
+ err2("the agent rewrote history out from under the anchor. That is the finding.");
9849
10252
  out(`
9850
10253
  tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
9851
10254
  return 1;
@@ -9879,33 +10282,34 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
9879
10282
  }
9880
10283
  const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
9881
10284
  if (mutatedDuringAdjudication) {
9882
- err("tamperward run: the working tree changed while it was being adjudicated \u2014");
9883
- err("the verdict below would describe a tree that no longer exists.");
10285
+ err2("tamperward run: the working tree changed while it was being adjudicated \u2014");
10286
+ err2("the verdict below would describe a tree that no longer exists.");
9884
10287
  }
9885
10288
  const dependencyAfter = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
9886
10289
  const depsDrifted = Boolean(dependencyEnvironment) && !opts.allowDepDrift && !dependencyAfter.ok;
9887
10290
  if (depsDrifted) {
9888
- err("tamperward run: the frozen dependency environment changed during this run \u2014");
9889
- err(`${dependencyAfter.reason ?? "dependency identity changed"}. Both verification runs depend on it.`);
10291
+ err2("tamperward run: the frozen dependency environment changed during this run \u2014");
10292
+ err2(`${dependencyAfter.reason ?? "dependency identity changed"}. Both verification runs depend on it.`);
9890
10293
  }
9891
10294
  const rewrote = objectRewriteState(cwd) !== rewritesBefore;
9892
10295
  if (rewrote) {
9893
- err("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
9894
- err("the checks above read the trusted base through an object layer that has since moved.");
10296
+ err2("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
10297
+ err2("the checks above read the trusted base through an object layer that has since moved.");
9895
10298
  }
9896
10299
  const survivors = survivorsHoldingTree(cwd, spawnTicks);
9897
10300
  if (survivors.length) {
9898
- err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
9899
- err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
10301
+ err2(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
10302
+ err2(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
9900
10303
  }
9901
10304
  const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
9902
10305
  const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
9903
10306
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
9904
10307
  const enforcement = cannot ? 2 : blocked ? 1 : 0;
9905
- const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
10308
+ const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentTimedOut ? "AGENT_TIMEOUT \u2014 runtime budget expired; post-timeout enforcement clean" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
10309
+ const agentSummary = agentTimedOut ? `AGENT_TIMEOUT (${opts.agentBudget}s; exit 124)` : `agent exit ${agentExit}`;
9906
10310
  out(`
9907
- tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
9908
- return enforcement !== 0 ? enforcement : agentExit;
10311
+ tamperward run \u2014 ${agentSummary}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
10312
+ return enforcement !== 0 ? enforcement : agentTimedOut ? 124 : agentExit;
9909
10313
  }
9910
10314
  function parseRun(args) {
9911
10315
  const o = { argv: [] };
@@ -9917,6 +10321,7 @@ function parseRun(args) {
9917
10321
  } else if (a === "--base") o.base = args[++i];
9918
10322
  else if (a === "--cmd") o.cmd = args[++i];
9919
10323
  else if (a === "--budget") o.budget = Number(args[++i]);
10324
+ else if (a === "--agent-budget") o.agentBudget = Number(args[++i]);
9920
10325
  else if (a === "--allow-dirty") o.allowDirty = true;
9921
10326
  else if (a === "--settle") o.settle = Number(args[++i]);
9922
10327
  else if (a === "--allow-dep-drift") o.allowDepDrift = true;
@@ -9960,6 +10365,16 @@ function parseInit(args) {
9960
10365
  }
9961
10366
  return o;
9962
10367
  }
10368
+ function parseDoctor(args) {
10369
+ const o = {};
10370
+ for (let i = 0; i < args.length; i++) {
10371
+ const a = args[i];
10372
+ if (a === "--cwd") o.cwd = args[++i];
10373
+ else if (a === "--base") o.base = args[++i];
10374
+ else if (a === "--workflow") o.workflow = args[++i];
10375
+ }
10376
+ return o;
10377
+ }
9963
10378
  function parseCheck(args) {
9964
10379
  const o = {};
9965
10380
  for (let i = 0; i < args.length; i++) {
@@ -10014,13 +10429,14 @@ Formats:
10014
10429
  fails closed (2)
10015
10430
  tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
10016
10431
  [--base R] [--cmd C] base, run the agent, treat its exit as
10017
- [--budget S] [--allow-dirty] untrusted, then re-adjudicate the tree it
10018
- [--settle S] [--allow-dep-drift] left (policy over base...HEAD and the
10019
- [--cwd D]
10020
- worktree, plus verify). Exit: agent's code
10021
- when clean; 1 on any blocking finding or
10022
- masked failure; 2 when it cannot
10023
- adjudicate (fails closed)
10432
+ [--budget S] [--agent-budget S] untrusted, then re-adjudicate the tree it
10433
+ [--allow-dirty] [--settle S] left (policy over base...HEAD and the
10434
+ [--allow-dep-drift] [--cwd D] worktree, plus verify). --agent-budget
10435
+ bounds the agent runtime itself and still
10436
+ adjudicates after timeout. Exit: agent's
10437
+ code when clean; 124 on clean AGENT_TIMEOUT;
10438
+ 1 on any blocking finding/masked failure;
10439
+ 2 when it cannot adjudicate (fails closed)
10024
10440
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
10025
10441
  [--file F] [--cwd D]
10026
10442
  tamperward init [--dry-run] wire the policy file plus every
@@ -10033,6 +10449,11 @@ Formats:
10033
10449
  every tool the gate must see.
10034
10450
  --force-workflow replaces a workflow it
10035
10451
  did not write, or one you have edited.
10452
+ tamperward doctor [--base R] validate the CI verifier's outer-time
10453
+ [--workflow F] [--cwd D] envelope against the trusted policy.
10454
+ Generated PR CI passes the base SHA so
10455
+ candidate policy edits cannot choose
10456
+ their own timing requirement.
10036
10457
 
10037
10458
  Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
10038
10459
  (verify), any blocking finding or masked failure (run) \xB7 2 cannot
@@ -10057,6 +10478,8 @@ function main(argv2) {
10057
10478
  return runAllow(parseAllow(rest));
10058
10479
  case "init":
10059
10480
  return runInit(parseInit(rest));
10481
+ case "doctor":
10482
+ return runDoctor(parseDoctor(rest));
10060
10483
  case "verify":
10061
10484
  return runVerify(parseVerify(rest));
10062
10485
  case "run":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.11.2",
3
+ "version": "2.12.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",