tamperward 2.12.0 → 2.13.1
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 +27 -10
- package/dist/cli/index.js +365 -42
- 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
|
|
@@ -323,9 +336,13 @@ from trusted CI, or after an externally isolated agent hands off the frozen cand
|
|
|
323
336
|
|
|
324
337
|
### CLI reference
|
|
325
338
|
|
|
326
|
-
Every flag below is what the command's parser actually reads (`src/cli/
|
|
327
|
-
`parseVerify`, `parseRun`).
|
|
328
|
-
|
|
339
|
+
Every flag below is what the command's parser actually reads (`src/cli/main.ts`,
|
|
340
|
+
`parseVerify`, `parseRun`). Since **2.13.1**, user-supplied argv is validated before
|
|
341
|
+
any command-specific parser or side effect runs: unknown options, missing values,
|
|
342
|
+
invalid numeric budgets/timeouts, duplicate/conflicting `check` views, and unexpected
|
|
343
|
+
positionals fail closed with exit 2 and one deterministic `tamperward: ...` diagnostic.
|
|
344
|
+
`run` requires an explicit `--` before the wrapped command, so a typoed TamperWard
|
|
345
|
+
option can never be reinterpreted as the agent command.
|
|
329
346
|
|
|
330
347
|
| command | flags |
|
|
331
348
|
| --- | --- |
|
package/dist/cli/index.js
CHANGED
|
@@ -308,8 +308,8 @@ function inspectOnce(abs2) {
|
|
|
308
308
|
try {
|
|
309
309
|
st = lstatSync(abs2);
|
|
310
310
|
} catch (e) {
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
311
|
+
const code2 = errCode(e);
|
|
312
|
+
if (code2 === "ENOENT" || code2 === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
313
313
|
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
314
314
|
}
|
|
315
315
|
if (st.isSymbolicLink() || !st.isFile()) return classify(abs2, st);
|
|
@@ -320,8 +320,8 @@ function inspectPath(abs2) {
|
|
|
320
320
|
try {
|
|
321
321
|
st = lstatSync(abs2);
|
|
322
322
|
} catch (e) {
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
323
|
+
const code2 = errCode(e);
|
|
324
|
+
if (code2 === "ENOENT" || code2 === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
325
325
|
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
326
326
|
}
|
|
327
327
|
return classify(abs2, st);
|
|
@@ -334,8 +334,8 @@ function inspectResolved(abs2) {
|
|
|
334
334
|
try {
|
|
335
335
|
real = realpathSync(abs2);
|
|
336
336
|
} catch (e) {
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
337
|
+
const code2 = errCode(e);
|
|
338
|
+
if (code2 === "ENOENT" || code2 === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
339
339
|
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
340
340
|
}
|
|
341
341
|
return inspectPath(real);
|
|
@@ -2669,8 +2669,8 @@ var CODE_PROBES = ["src/index.ts", "src/index.js", "lib/index.js", "index.ts", "
|
|
|
2669
2669
|
function sourceProbes(ctx) {
|
|
2670
2670
|
const files = trackedFiles(ctx);
|
|
2671
2671
|
if (!files) return CODE_PROBES;
|
|
2672
|
-
const
|
|
2673
|
-
return
|
|
2672
|
+
const code2 = files.filter((f) => langOf(f) !== null);
|
|
2673
|
+
return code2.length ? code2 : CODE_PROBES;
|
|
2674
2674
|
}
|
|
2675
2675
|
function triggerNarrowings(before, after, opts) {
|
|
2676
2676
|
const out2 = [];
|
|
@@ -6535,8 +6535,8 @@ function terminalWidth(stream = process.stdout) {
|
|
|
6535
6535
|
if (!c || c < 40) return 80;
|
|
6536
6536
|
return Math.min(c, 100);
|
|
6537
6537
|
}
|
|
6538
|
-
function paint(s,
|
|
6539
|
-
return on ?
|
|
6538
|
+
function paint(s, code2, on) {
|
|
6539
|
+
return on ? code2 + s + RESET : s;
|
|
6540
6540
|
}
|
|
6541
6541
|
function wrap(text, width) {
|
|
6542
6542
|
const out2 = [];
|
|
@@ -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,
|
|
@@ -9138,8 +9317,8 @@ function runContainerStage(backend, dir, command, budgetSecs) {
|
|
|
9138
9317
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
9139
9318
|
if (r.error) {
|
|
9140
9319
|
cleanupContainer(backend, name);
|
|
9141
|
-
const
|
|
9142
|
-
return
|
|
9320
|
+
const code2 = r.error.code;
|
|
9321
|
+
return code2 === "ETIMEDOUT" ? { exit: null, secs, failure: "budget", reason: "verifier stage exceeded its budget" } : { exit: null, secs, failure: "backend", reason: `Docker client failed: ${r.error.message}` };
|
|
9143
9322
|
}
|
|
9144
9323
|
if (r.status === null) {
|
|
9145
9324
|
cleanupContainer(backend, name);
|
|
@@ -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 });
|
|
@@ -9763,22 +9942,22 @@ function runVerify(opts) {
|
|
|
9763
9942
|
return 2;
|
|
9764
9943
|
}
|
|
9765
9944
|
let verdict2;
|
|
9766
|
-
let
|
|
9945
|
+
let code2;
|
|
9767
9946
|
if (visible.exit === null || pristine.exit === null) {
|
|
9768
9947
|
verdict2 = "BUDGET_EXCEEDED";
|
|
9769
|
-
|
|
9948
|
+
code2 = 2;
|
|
9770
9949
|
} else if (visible.exit === 0 && pristine.exit === 0) {
|
|
9771
9950
|
verdict2 = "VERIFIED";
|
|
9772
|
-
|
|
9951
|
+
code2 = 0;
|
|
9773
9952
|
} else if (visible.exit === 0) {
|
|
9774
9953
|
verdict2 = "MASKED_FAILURE";
|
|
9775
|
-
|
|
9954
|
+
code2 = 1;
|
|
9776
9955
|
} else {
|
|
9777
9956
|
verdict2 = "SUITE_RED";
|
|
9778
|
-
|
|
9957
|
+
code2 = 1;
|
|
9779
9958
|
}
|
|
9780
9959
|
const signedOff = verdict2 === "MASKED_FAILURE" ? oobToken("verify", oobFromEnv(), oobHeadFromEnv()) : null;
|
|
9781
|
-
if (signedOff)
|
|
9960
|
+
if (signedOff) code2 = 0;
|
|
9782
9961
|
if (opts.json) {
|
|
9783
9962
|
out2(
|
|
9784
9963
|
JSON.stringify({
|
|
@@ -9821,7 +10000,7 @@ function runVerify(opts) {
|
|
|
9821
10000
|
`(${removedAdded} protected file(s) added since ${base.slice(0, 10)} were removed from the pristine run: the pristine tree carries exactly the base's protected surface.)`
|
|
9822
10001
|
);
|
|
9823
10002
|
}
|
|
9824
|
-
return
|
|
10003
|
+
return code2;
|
|
9825
10004
|
}
|
|
9826
10005
|
function parseVerify(args) {
|
|
9827
10006
|
const o = {};
|
|
@@ -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
|
}
|
|
@@ -10385,17 +10567,148 @@ function parseCheck(args) {
|
|
|
10385
10567
|
else if (a === "--format") {
|
|
10386
10568
|
const v = args[++i];
|
|
10387
10569
|
if (v !== void 0 && isFormat(v)) o.format = v;
|
|
10388
|
-
else process.stderr.write(`tamperward: unknown --format "${v ?? ""}" (expected ${FORMATS.join(" | ")})
|
|
10389
|
-
`);
|
|
10390
10570
|
} else if (a === "--diff") o.diff = args[++i];
|
|
10391
10571
|
else if (a === "--cwd") o.cwd = args[++i];
|
|
10392
|
-
else {
|
|
10393
|
-
process.stderr.write(`tamperward: unknown flag "${a}"
|
|
10394
|
-
`);
|
|
10395
|
-
}
|
|
10396
10572
|
}
|
|
10397
10573
|
return o;
|
|
10398
10574
|
}
|
|
10575
|
+
function validateFlatArgs(args, grammar) {
|
|
10576
|
+
const flags = new Set(grammar.flags ?? []);
|
|
10577
|
+
const values = grammar.values ?? {};
|
|
10578
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10579
|
+
const positionals = [];
|
|
10580
|
+
for (let i = 0; i < args.length; i++) {
|
|
10581
|
+
const a = args[i];
|
|
10582
|
+
if (flags.has(a) || Object.prototype.hasOwnProperty.call(values, a)) {
|
|
10583
|
+
if (seen.has(a)) {
|
|
10584
|
+
return { error: `option "${a}" specified more than once`, seen, positionals };
|
|
10585
|
+
}
|
|
10586
|
+
seen.add(a);
|
|
10587
|
+
const rule = values[a];
|
|
10588
|
+
if (!rule) continue;
|
|
10589
|
+
const v = args[i + 1];
|
|
10590
|
+
if (v === void 0 || v.startsWith("--")) {
|
|
10591
|
+
return {
|
|
10592
|
+
error: `${a} needs a value` + (v === void 0 ? "" : ` (got the flag "${v}")`),
|
|
10593
|
+
seen,
|
|
10594
|
+
positionals
|
|
10595
|
+
};
|
|
10596
|
+
}
|
|
10597
|
+
i++;
|
|
10598
|
+
if (rule === "positive") {
|
|
10599
|
+
const n = Number(v);
|
|
10600
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
10601
|
+
return { error: `${a} needs a positive number (got "${v}")`, seen, positionals };
|
|
10602
|
+
}
|
|
10603
|
+
} else if (rule === "non-negative") {
|
|
10604
|
+
const n = Number(v);
|
|
10605
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
10606
|
+
return { error: `${a} needs a non-negative number (got "${v}")`, seen, positionals };
|
|
10607
|
+
}
|
|
10608
|
+
} else if (rule === "format" && !isFormat(v)) {
|
|
10609
|
+
return {
|
|
10610
|
+
error: `--format needs one of ${FORMATS.join(" | ")} (got "${v}")`,
|
|
10611
|
+
seen,
|
|
10612
|
+
positionals
|
|
10613
|
+
};
|
|
10614
|
+
}
|
|
10615
|
+
continue;
|
|
10616
|
+
}
|
|
10617
|
+
if (a.startsWith("-")) {
|
|
10618
|
+
return { error: `unknown option "${a}"`, seen, positionals };
|
|
10619
|
+
}
|
|
10620
|
+
if (grammar.positional === "one" && positionals.length === 0) {
|
|
10621
|
+
positionals.push(a);
|
|
10622
|
+
continue;
|
|
10623
|
+
}
|
|
10624
|
+
return { error: `unexpected argument "${a}"`, seen, positionals };
|
|
10625
|
+
}
|
|
10626
|
+
return { seen, positionals };
|
|
10627
|
+
}
|
|
10628
|
+
function validateCliArgs(cmd, args) {
|
|
10629
|
+
if (cmd === "hook" || cmd === "sweep") {
|
|
10630
|
+
if (args.length === 0) return `${cmd} requires an agent name`;
|
|
10631
|
+
if (args.length > 1) return `unexpected argument "${args[1]}"`;
|
|
10632
|
+
return void 0;
|
|
10633
|
+
}
|
|
10634
|
+
if (cmd === "run") {
|
|
10635
|
+
const delimiter3 = args.indexOf("--");
|
|
10636
|
+
if (delimiter3 < 0) return 'run requires an explicit "--" before the wrapped command';
|
|
10637
|
+
const prefix = args.slice(0, delimiter3);
|
|
10638
|
+
const command = args.slice(delimiter3 + 1);
|
|
10639
|
+
const parsed = validateFlatArgs(prefix, {
|
|
10640
|
+
flags: ["--allow-dirty", "--allow-dep-drift"],
|
|
10641
|
+
values: {
|
|
10642
|
+
"--base": "string",
|
|
10643
|
+
"--cmd": "string",
|
|
10644
|
+
"--budget": "positive",
|
|
10645
|
+
"--agent-budget": "positive",
|
|
10646
|
+
"--settle": "non-negative",
|
|
10647
|
+
"--cwd": "string"
|
|
10648
|
+
}
|
|
10649
|
+
});
|
|
10650
|
+
if (parsed.error) return parsed.error;
|
|
10651
|
+
if (command.length === 0) return 'run requires a wrapped command after "--"';
|
|
10652
|
+
return void 0;
|
|
10653
|
+
}
|
|
10654
|
+
if (cmd === "check") {
|
|
10655
|
+
const parsed = validateFlatArgs(args, {
|
|
10656
|
+
flags: ["--staged", "--worktree", "--json"],
|
|
10657
|
+
values: { "--diff": "string", "--format": "format", "--cwd": "string" }
|
|
10658
|
+
});
|
|
10659
|
+
if (parsed.error) return parsed.error;
|
|
10660
|
+
const views = ["--staged", "--worktree", "--diff"].filter((x) => parsed.seen.has(x));
|
|
10661
|
+
if (views.length > 1) return "choose exactly one of --staged, --worktree, or --diff";
|
|
10662
|
+
if (parsed.seen.has("--json") && parsed.seen.has("--format")) {
|
|
10663
|
+
return "--json cannot be combined with --format";
|
|
10664
|
+
}
|
|
10665
|
+
return void 0;
|
|
10666
|
+
}
|
|
10667
|
+
if (cmd === "allow") {
|
|
10668
|
+
const parsed = validateFlatArgs(args, {
|
|
10669
|
+
values: { "--file": "string", "--reason": "string", "--cwd": "string" },
|
|
10670
|
+
positional: "one"
|
|
10671
|
+
});
|
|
10672
|
+
if (parsed.error) return parsed.error;
|
|
10673
|
+
if (parsed.positionals.length === 0) return "allow requires a rule";
|
|
10674
|
+
return void 0;
|
|
10675
|
+
}
|
|
10676
|
+
if (cmd === "init") {
|
|
10677
|
+
return validateFlatArgs(args, {
|
|
10678
|
+
flags: ["--dry-run", "--force-workflow"],
|
|
10679
|
+
values: { "--cwd": "string" }
|
|
10680
|
+
}).error;
|
|
10681
|
+
}
|
|
10682
|
+
if (cmd === "doctor") {
|
|
10683
|
+
return validateFlatArgs(args, {
|
|
10684
|
+
flags: ["--github"],
|
|
10685
|
+
values: {
|
|
10686
|
+
"--cwd": "string",
|
|
10687
|
+
"--base": "string",
|
|
10688
|
+
"--workflow": "string",
|
|
10689
|
+
"--repo": "string",
|
|
10690
|
+
"--branch": "string"
|
|
10691
|
+
}
|
|
10692
|
+
}).error;
|
|
10693
|
+
}
|
|
10694
|
+
if (cmd === "verify") {
|
|
10695
|
+
return validateFlatArgs(args, {
|
|
10696
|
+
flags: ["--json", "--keep", "--require-ancestor"],
|
|
10697
|
+
values: {
|
|
10698
|
+
"--base": "string",
|
|
10699
|
+
"--cmd": "string",
|
|
10700
|
+
"--cwd": "string",
|
|
10701
|
+
"--budget": "positive"
|
|
10702
|
+
}
|
|
10703
|
+
}).error;
|
|
10704
|
+
}
|
|
10705
|
+
if (cmd === "watch") {
|
|
10706
|
+
return validateFlatArgs(args, {
|
|
10707
|
+
values: { "--dir": "string", "--log": "string" }
|
|
10708
|
+
}).error;
|
|
10709
|
+
}
|
|
10710
|
+
return void 0;
|
|
10711
|
+
}
|
|
10399
10712
|
function printHelp() {
|
|
10400
10713
|
process.stdout.write(`tamperward \u2014 the deterministic agent-integrity gate
|
|
10401
10714
|
|
|
@@ -10451,9 +10764,12 @@ Formats:
|
|
|
10451
10764
|
did not write, or one you have edited.
|
|
10452
10765
|
tamperward doctor [--base R] validate the CI verifier's outer-time
|
|
10453
10766
|
[--workflow F] [--cwd D] envelope against the trusted policy.
|
|
10454
|
-
|
|
10455
|
-
|
|
10456
|
-
|
|
10767
|
+
[--github] [--repo O/R] --github also validates repository
|
|
10768
|
+
[--branch B] authority: required tamperward status,
|
|
10769
|
+
Code Owner review, and stale-review
|
|
10770
|
+
dismissal after new pushes. Public
|
|
10771
|
+
rules can be read anonymously; set
|
|
10772
|
+
GH_TOKEN/GITHUB_TOKEN when needed.
|
|
10457
10773
|
|
|
10458
10774
|
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
10459
10775
|
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
@@ -10467,6 +10783,14 @@ Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_R
|
|
|
10467
10783
|
}
|
|
10468
10784
|
function main(argv2) {
|
|
10469
10785
|
const [cmd, ...rest] = argv2;
|
|
10786
|
+
if (cmd !== void 0 && cmd !== "-h" && cmd !== "--help") {
|
|
10787
|
+
const invalid = validateCliArgs(cmd, rest);
|
|
10788
|
+
if (invalid) {
|
|
10789
|
+
process.stderr.write(`tamperward: ${invalid}
|
|
10790
|
+
`);
|
|
10791
|
+
return 2;
|
|
10792
|
+
}
|
|
10793
|
+
}
|
|
10470
10794
|
switch (cmd) {
|
|
10471
10795
|
case "check":
|
|
10472
10796
|
return runCheck(parseCheck(rest));
|
|
@@ -10480,6 +10804,8 @@ function main(argv2) {
|
|
|
10480
10804
|
return runInit(parseInit(rest));
|
|
10481
10805
|
case "doctor":
|
|
10482
10806
|
return runDoctor(parseDoctor(rest));
|
|
10807
|
+
case "watch":
|
|
10808
|
+
return runWatch(rest);
|
|
10483
10809
|
case "verify":
|
|
10484
10810
|
return runVerify(parseVerify(rest));
|
|
10485
10811
|
case "run":
|
|
@@ -10509,8 +10835,5 @@ function guardedMain(argv2, stderr = process.stderr) {
|
|
|
10509
10835
|
|
|
10510
10836
|
// src/cli/index.ts
|
|
10511
10837
|
var argv = process.argv.slice(2);
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
} else {
|
|
10515
|
-
process.exit(guardedMain(argv));
|
|
10516
|
-
}
|
|
10838
|
+
var code = guardedMain(argv);
|
|
10839
|
+
if (code >= 0) process.exit(code);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.1",
|
|
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",
|