tamperward 2.12.0 → 2.13.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
@@ -254,13 +254,26 @@ CI workflow that runs both the diff-time check and pristine verification, and a
254
254
  `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
255
255
  It never overwrites anything you wrote; `--dry-run` prints the plan.
256
256
 
257
- **`init` is not sufficient on its own, and it will tell you so.** In your
258
- branch-protection or ruleset settings you must require the check *and* enable
259
- "Require review from Code Owners". A `pull_request` workflow runs from the pull
260
- request's own head and a required check is matched by job name, so without that
261
- a PR can keep the job name, replace the gate with `true`, and present a green
262
- required check over a change the gate would have blocked. That is reproduced on
263
- this project's own CI, not a theoretical concern.
257
+ **`init` is not sufficient on its own, and it will tell you so.** The protected
258
+ branch needs all three repository-authority controls:
259
+
260
+ 1. require the **`tamperward`** status check;
261
+ 2. enable **Require review from Code Owners**; and
262
+ 3. enable **Dismiss stale pull request approvals when new commits are pushed**.
263
+
264
+ The third control is load-bearing: an approval for an older gate-critical diff must
265
+ not authorize a later push. GitHub's "require approval of the most recent reviewable
266
+ push" can be useful in addition, but it is not equivalent here because that fresh
267
+ approver is not necessarily the Code Owner for the gate path. A `pull_request`
268
+ workflow runs from the pull request's own head and a required check is matched by job
269
+ name, so without this human boundary a PR can keep the job name, replace the gate with
270
+ `true`, and present a green required check over a change the gate would have blocked.
271
+ That is reproduced on this project's own CI, not a theoretical concern.
272
+
273
+ After configuring GitHub, verify the boundary with
274
+ `tamperward doctor --github --repo OWNER/REPO --branch <default-branch>`.
275
+ Public rulesets can be read anonymously; set `GH_TOKEN` or `GITHUB_TOKEN` when
276
+ authentication is required.
264
277
 
265
278
  A real deployment needs a verify command configured — the generated CI verify step
266
279
  **fails closed (exit 2) without one** rather than passing quietly. In
package/dist/cli/index.js CHANGED
@@ -8221,12 +8221,13 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
8221
8221
  `
8222
8222
  );
8223
8223
  w.write(
8224
- '\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, require the tamperward check AND enable\n"Require review from Code Owners".\n\nUntil you do, the CI gate is advisory. A pull request runs the workflow from\nits OWN head, and a required check is matched by job name \u2014 so a pull request\ncan keep the job called `tamperward`, replace the gate with `true`, and present\na green required check over a change the gate would have blocked. That is\nreproduced, not theoretical. CODEOWNERS is what puts a human in front of it;\nbranch protection is what makes CODEOWNERS bind.\n'
8224
+ '\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, configure ALL THREE repository-authority controls:\n 1. require the tamperward check;\n 2. enable "Require review from Code Owners"; and\n 3. enable "Dismiss stale pull request approvals when new commits are pushed".\n\nThe freshness control is part of the security boundary: an approval for an older\ngate-critical diff must not authorize a later push. "Require approval of the most\nrecent reviewable push" can be useful in addition, but is not a substitute here\nbecause the fresh approver is not necessarily the Code Owner for the gate path.\n\nUntil all three are enforced, the CI gate is advisory. A pull request runs the\nworkflow from its OWN head, and a required check is matched by job name \u2014 so a\npull request can keep the job called `tamperward`, replace the gate with `true`,\nand present a green required check over a change the gate would have blocked.\nCODEOWNERS puts a human in front of gate-critical paths; branch protection or an\nactive ruleset makes that review fresh and binding. Verify with: tamperward doctor\n--github --repo OWNER/REPO --branch <default-branch>.\n'
8225
8225
  );
8226
8226
  return 0;
8227
8227
  }
8228
8228
 
8229
8229
  // src/cli/doctor.ts
8230
+ import { execFileSync as execFileSync7 } from "node:child_process";
8230
8231
  import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync as readdirSync3 } from "node:fs";
8231
8232
  import { join as join13, resolve as resolve4 } from "node:path";
8232
8233
  import { parse as parse3 } from "yaml";
@@ -8244,6 +8245,166 @@ function policyFor(opts, cwd) {
8244
8245
  function asMapping(value) {
8245
8246
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
8246
8247
  }
8248
+ function evaluateGitHubProtection(snapshot2, requiredCheck = "tamperward") {
8249
+ let hasRequiredCheck = false;
8250
+ let hasCodeOwnerReview = false;
8251
+ let dismissesStaleReviews = false;
8252
+ if (Array.isArray(snapshot2.rules)) {
8253
+ for (const raw of snapshot2.rules) {
8254
+ const rule = asMapping(raw);
8255
+ const params = asMapping(rule?.parameters);
8256
+ if (!rule || !params) continue;
8257
+ if (rule.type === "pull_request") {
8258
+ if (params.require_code_owner_review === true) hasCodeOwnerReview = true;
8259
+ if (params.dismiss_stale_reviews_on_push === true) dismissesStaleReviews = true;
8260
+ }
8261
+ if (rule.type === "required_status_checks") {
8262
+ const checks = params.required_status_checks;
8263
+ if (Array.isArray(checks)) {
8264
+ hasRequiredCheck ||= checks.some((rawCheck) => {
8265
+ const check2 = asMapping(rawCheck);
8266
+ return check2?.context === requiredCheck;
8267
+ });
8268
+ }
8269
+ }
8270
+ }
8271
+ }
8272
+ const classic = asMapping(snapshot2.branchProtection);
8273
+ const reviews = asMapping(classic?.required_pull_request_reviews);
8274
+ if (reviews?.require_code_owner_reviews === true) hasCodeOwnerReview = true;
8275
+ if (reviews?.dismiss_stale_reviews === true) dismissesStaleReviews = true;
8276
+ const status = asMapping(classic?.required_status_checks);
8277
+ if (status) {
8278
+ const contexts = status.contexts;
8279
+ if (Array.isArray(contexts)) {
8280
+ hasRequiredCheck ||= contexts.some((x) => x === requiredCheck);
8281
+ }
8282
+ const checks = status.checks;
8283
+ if (Array.isArray(checks)) {
8284
+ hasRequiredCheck ||= checks.some((rawCheck) => {
8285
+ const check2 = asMapping(rawCheck);
8286
+ return check2?.context === requiredCheck;
8287
+ });
8288
+ }
8289
+ }
8290
+ const findings = [];
8291
+ if (!hasRequiredCheck) findings.push("require the tamperward status check");
8292
+ if (!hasCodeOwnerReview) findings.push("require Code Owner review");
8293
+ if (!dismissesStaleReviews) findings.push("dismiss stale pull request approvals on new pushes");
8294
+ return findings;
8295
+ }
8296
+ function gitText(cwd, args) {
8297
+ try {
8298
+ return execFileSync7("git", args, {
8299
+ cwd,
8300
+ encoding: "utf8",
8301
+ stdio: ["ignore", "pipe", "ignore"]
8302
+ }).trim();
8303
+ } catch {
8304
+ return null;
8305
+ }
8306
+ }
8307
+ function githubRepoFromRemote(remote) {
8308
+ const m = remote.match(/github\.com(?::|\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
8309
+ return m ? m[1] + "/" + m[2].replace(/\.git$/i, "") : null;
8310
+ }
8311
+ function inferGitHubRepo(cwd) {
8312
+ const envRepo = process.env.GITHUB_REPOSITORY;
8313
+ if (envRepo && /^[^/\s]+\/[^/\s]+$/.test(envRepo)) return envRepo;
8314
+ const remote = gitText(cwd, ["config", "--get", "remote.origin.url"]);
8315
+ return remote ? githubRepoFromRemote(remote) : null;
8316
+ }
8317
+ var GITHUB_API_SCRIPT = [
8318
+ "const endpoint = process.argv[1];",
8319
+ "const token = process.env.TAMPERWARD_GITHUB_TOKEN || '';",
8320
+ "const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', 'User-Agent': 'tamperward-doctor' };",
8321
+ "if (token) headers.Authorization = 'Bearer ' + token;",
8322
+ "fetch('https://api.github.com/' + endpoint, { headers }).then(async (r) => {",
8323
+ " const body = await r.text();",
8324
+ " if (!r.ok) { process.stderr.write('HTTP ' + r.status + ': ' + body); process.exit(22); }",
8325
+ " process.stdout.write(body);",
8326
+ "}).catch((e) => { process.stderr.write(String(e)); process.exit(23); });"
8327
+ ].join("\n");
8328
+ function githubApiInvocation(endpoint, token) {
8329
+ return {
8330
+ executable: process.execPath,
8331
+ args: ["-e", GITHUB_API_SCRIPT, endpoint],
8332
+ env: {
8333
+ TAMPERWARD_GITHUB_TOKEN: token,
8334
+ LANG: "C",
8335
+ LC_ALL: "C"
8336
+ }
8337
+ };
8338
+ }
8339
+ function githubApi(cwd, endpoint) {
8340
+ const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? "";
8341
+ const invocation = githubApiInvocation(endpoint, token);
8342
+ try {
8343
+ const stdout = execFileSync7(
8344
+ invocation.executable,
8345
+ invocation.args,
8346
+ {
8347
+ cwd,
8348
+ encoding: "utf8",
8349
+ stdio: ["ignore", "pipe", "pipe"],
8350
+ env: invocation.env,
8351
+ timeout: 15e3
8352
+ }
8353
+ );
8354
+ return JSON.parse(stdout);
8355
+ } catch (e) {
8356
+ const x = e;
8357
+ const detail = x.stderr ? String(x.stderr).replace(/\s+/g, " ").trim() : x.message;
8358
+ throw new Error(
8359
+ "GitHub API " + endpoint + " failed: " + (detail || "unknown error")
8360
+ );
8361
+ }
8362
+ }
8363
+ function githubAuthority(opts, cwd) {
8364
+ const repo = opts.repo ?? inferGitHubRepo(cwd);
8365
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
8366
+ throw new Error(
8367
+ "cannot determine GitHub repository; pass --repo OWNER/REPO or configure a github.com origin"
8368
+ );
8369
+ }
8370
+ let branch = opts.branch ?? process.env.GITHUB_BASE_REF ?? "";
8371
+ if (!branch) {
8372
+ const meta2 = asMapping(githubApi(cwd, "repos/" + repo));
8373
+ if (typeof meta2?.default_branch === "string") branch = meta2.default_branch;
8374
+ }
8375
+ if (!branch) throw new Error("cannot determine protected branch; pass --branch BRANCH");
8376
+ let rules;
8377
+ let rulesError = null;
8378
+ try {
8379
+ rules = githubApi(cwd, "repos/" + repo + "/rules/branches/" + encodeURIComponent(branch));
8380
+ } catch (e) {
8381
+ rulesError = e instanceof Error ? e : new Error(String(e));
8382
+ }
8383
+ let findings = evaluateGitHubProtection({ rules });
8384
+ if (findings.length === 0) return { repo, branch, findings };
8385
+ let branchProtection;
8386
+ let classicError = null;
8387
+ try {
8388
+ branchProtection = githubApi(
8389
+ cwd,
8390
+ "repos/" + repo + "/branches/" + encodeURIComponent(branch) + "/protection"
8391
+ );
8392
+ } catch (e) {
8393
+ classicError = e instanceof Error ? e : new Error(String(e));
8394
+ }
8395
+ findings = evaluateGitHubProtection({ rules, branchProtection });
8396
+ if (findings.length > 0 && rules === void 0 && branchProtection === void 0) {
8397
+ throw new Error(
8398
+ "cannot inspect GitHub repository authority. Rules API: " + (rulesError?.message ?? "unavailable") + ". Classic branch protection API: " + (classicError?.message ?? "unavailable") + ". Authenticate gh with metadata read; classic-protection fallback may also need Administration read."
8399
+ );
8400
+ }
8401
+ if (findings.length > 0 && branchProtection === void 0 && classicError) {
8402
+ findings.push(
8403
+ "classic branch protection could not be inspected; if it supplies a missing requirement, use GH_TOKEN/GITHUB_TOKEN with Administration read"
8404
+ );
8405
+ }
8406
+ return { repo, branch, findings };
8407
+ }
8247
8408
  function verifyJobs(doc) {
8248
8409
  const root = asMapping(doc);
8249
8410
  const jobs = asMapping(root?.jobs);
@@ -8346,25 +8507,43 @@ function runDoctor(opts = {}) {
8346
8507
  const scope = opts.workflow ?? ".github/workflows/*.yml|*.yaml";
8347
8508
  return err(`${scope}: no job contains a tamperward verify step`);
8348
8509
  }
8510
+ let github = null;
8511
+ if (opts.github) {
8512
+ try {
8513
+ github = githubAuthority(opts, cwd);
8514
+ } catch (e) {
8515
+ return err(e instanceof Error ? e.message : String(e));
8516
+ }
8517
+ if (github.findings.length > 0) {
8518
+ return err(
8519
+ "GitHub repository authority for " + github.repo + "#" + github.branch + " is incomplete: " + github.findings.join("; ")
8520
+ );
8521
+ }
8522
+ }
8349
8523
  process.stdout.write(
8350
8524
  `tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
8351
8525
  `
8352
8526
  );
8527
+ if (github) {
8528
+ process.stdout.write(
8529
+ "tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes.\n"
8530
+ );
8531
+ }
8353
8532
  return 0;
8354
8533
  }
8355
8534
 
8356
8535
  // src/cli/verify.ts
8357
- import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
8536
+ import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
8358
8537
  import { createHash as createHash10 } from "node:crypto";
8359
8538
  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";
8360
8539
  import { tmpdir as tmpdir2 } from "node:os";
8361
8540
  import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
8362
8541
 
8363
8542
  // src/fingerprint.ts
8364
- import { execFileSync as execFileSync7 } from "node:child_process";
8543
+ import { execFileSync as execFileSync8 } from "node:child_process";
8365
8544
  import { createHash as createHash7 } from "node:crypto";
8366
8545
  function treeFingerprint(cwd, keepIgnored) {
8367
- const listed = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
8546
+ const listed = execFileSync8("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
8368
8547
  cwd,
8369
8548
  encoding: "utf8",
8370
8549
  maxBuffer: 1 << 28,
@@ -9271,7 +9450,7 @@ var VERIFICATION_SURFACE = [
9271
9450
  "**/*.runsettings"
9272
9451
  ];
9273
9452
  function git2(args, cwd) {
9274
- return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
9453
+ return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
9275
9454
  }
9276
9455
  function resolveBase(base, cwd) {
9277
9456
  assertRev(base);
@@ -9467,7 +9646,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
9467
9646
  if (e.type === "commit") {
9468
9647
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
9469
9648
  }
9470
- const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
9649
+ const content = execFileSync9("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
9471
9650
  const out2 = join16(dest, e.path);
9472
9651
  mkdirSync6(dirname7(out2), { recursive: true });
9473
9652
  rmSync3(out2, { force: true });
@@ -9856,7 +10035,7 @@ function parseVerify(args) {
9856
10035
  }
9857
10036
 
9858
10037
  // src/cli/run.ts
9859
- import { execFileSync as execFileSync9, spawnSync as spawnSync3 } from "node:child_process";
10038
+ import { execFileSync as execFileSync10, spawnSync as spawnSync3 } from "node:child_process";
9860
10039
  import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync17, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
9861
10040
  import { tmpdir as tmpdir3 } from "node:os";
9862
10041
  import { join as join17, resolve as resolve8 } from "node:path";
@@ -10079,7 +10258,7 @@ function runAgentWithBudget(argv2, cwd, budgetSecs) {
10079
10258
  }
10080
10259
  }
10081
10260
  function git3(args, cwd) {
10082
- return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
10261
+ return execFileSync10("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
10083
10262
  }
10084
10263
  function nowTicks() {
10085
10264
  try {
@@ -10276,7 +10455,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
10276
10455
  });
10277
10456
  if (opts.settle && opts.settle > 0) {
10278
10457
  try {
10279
- execFileSync9("sleep", [String(opts.settle)]);
10458
+ execFileSync10("sleep", [String(opts.settle)]);
10280
10459
  } catch {
10281
10460
  }
10282
10461
  }
@@ -10372,6 +10551,9 @@ function parseDoctor(args) {
10372
10551
  if (a === "--cwd") o.cwd = args[++i];
10373
10552
  else if (a === "--base") o.base = args[++i];
10374
10553
  else if (a === "--workflow") o.workflow = args[++i];
10554
+ else if (a === "--github") o.github = true;
10555
+ else if (a === "--repo") o.repo = args[++i];
10556
+ else if (a === "--branch") o.branch = args[++i];
10375
10557
  }
10376
10558
  return o;
10377
10559
  }
@@ -10451,9 +10633,12 @@ Formats:
10451
10633
  did not write, or one you have edited.
10452
10634
  tamperward doctor [--base R] validate the CI verifier's outer-time
10453
10635
  [--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.
10636
+ [--github] [--repo O/R] --github also validates repository
10637
+ [--branch B] authority: required tamperward status,
10638
+ Code Owner review, and stale-review
10639
+ dismissal after new pushes. Public
10640
+ rules can be read anonymously; set
10641
+ GH_TOKEN/GITHUB_TOKEN when needed.
10457
10642
 
10458
10643
  Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
10459
10644
  (verify), any blocking finding or masked failure (run) \xB7 2 cannot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",