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.
Files changed (3) hide show
  1. package/README.md +27 -10
  2. package/dist/cli/index.js +365 -42
  3. 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.** 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
@@ -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/index.ts`,
327
- `parseVerify`, `parseRun`). `check` reports an unknown flag on stderr; the other
328
- commands ignore what they do not know.
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 code = errCode(e);
312
- if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
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 code = errCode(e);
324
- if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
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 code = errCode(e);
338
- if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
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 code = files.filter((f) => langOf(f) !== null);
2673
- return code.length ? code : CODE_PROBES;
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, code, on) {
6539
- return on ? code + s + RESET : s;
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 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,
@@ -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 code = r.error.code;
9142
- return code === "ETIMEDOUT" ? { exit: null, secs, failure: "budget", reason: "verifier stage exceeded its budget" } : { exit: null, secs, failure: "backend", reason: `Docker client failed: ${r.error.message}` };
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 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 });
@@ -9763,22 +9942,22 @@ function runVerify(opts) {
9763
9942
  return 2;
9764
9943
  }
9765
9944
  let verdict2;
9766
- let code;
9945
+ let code2;
9767
9946
  if (visible.exit === null || pristine.exit === null) {
9768
9947
  verdict2 = "BUDGET_EXCEEDED";
9769
- code = 2;
9948
+ code2 = 2;
9770
9949
  } else if (visible.exit === 0 && pristine.exit === 0) {
9771
9950
  verdict2 = "VERIFIED";
9772
- code = 0;
9951
+ code2 = 0;
9773
9952
  } else if (visible.exit === 0) {
9774
9953
  verdict2 = "MASKED_FAILURE";
9775
- code = 1;
9954
+ code2 = 1;
9776
9955
  } else {
9777
9956
  verdict2 = "SUITE_RED";
9778
- code = 1;
9957
+ code2 = 1;
9779
9958
  }
9780
9959
  const signedOff = verdict2 === "MASKED_FAILURE" ? oobToken("verify", oobFromEnv(), oobHeadFromEnv()) : null;
9781
- if (signedOff) code = 0;
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 code;
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 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
  }
@@ -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
- Generated PR CI passes the base SHA so
10455
- candidate policy edits cannot choose
10456
- their own timing requirement.
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
- if (argv[0] === "watch") {
10513
- runWatch(argv.slice(1));
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.12.0",
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",