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 +20 -7
- package/dist/cli/index.js +197 -12
- package/package.json +1 -1
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.**
|
|
258
|
-
branch
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10455
|
-
|
|
10456
|
-
|
|
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.
|
|
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",
|