@cldmv/gh-broker 0.9.0 → 0.10.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/dist/index.mjs CHANGED
@@ -7405,6 +7405,7 @@ var require_content_type = __commonJS({
7405
7405
 
7406
7406
  // src/config.ts
7407
7407
  import { readFileSync } from "node:fs";
7408
+ import { isAbsolute as isAbsolute3 } from "node:path";
7408
7409
 
7409
7410
  // src/verbs/ghcr/config.ts
7410
7411
  import { isAbsolute, normalize } from "node:path";
@@ -7473,6 +7474,184 @@ function imageAllowed(owner2, image2) {
7473
7474
  return owner2.images.some((g) => imageGlobToRegExp(g).test(image2));
7474
7475
  }
7475
7476
 
7477
+ // src/otp/totp.ts
7478
+ import { createHmac, timingSafeEqual } from "node:crypto";
7479
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
7480
+ var STEP_SECONDS = 30;
7481
+ function base32Decode(text) {
7482
+ const clean = text.toUpperCase().replace(/=+$/, "");
7483
+ const out = [];
7484
+ let bits = 0;
7485
+ let value = 0;
7486
+ for (const ch of clean) {
7487
+ const idx = BASE32_ALPHABET.indexOf(ch);
7488
+ if (idx < 0) throw new Error("base32: invalid character");
7489
+ value = value << 5 | idx;
7490
+ bits += 5;
7491
+ if (bits >= 8) {
7492
+ out.push(value >>> bits - 8 & 255);
7493
+ bits -= 8;
7494
+ }
7495
+ }
7496
+ return Buffer.from(out);
7497
+ }
7498
+ function hotp(key2, counter, digits = 6) {
7499
+ const msg = Buffer.alloc(8);
7500
+ msg.writeBigUInt64BE(BigInt(counter));
7501
+ const mac2 = createHmac("sha1", key2).update(msg).digest();
7502
+ const offset = mac2[mac2.length - 1] & 15;
7503
+ const bin = (mac2[offset] & 127) << 24 | mac2[offset + 1] << 16 | mac2[offset + 2] << 8 | mac2[offset + 3];
7504
+ return String(bin % 10 ** digits).padStart(digits, "0");
7505
+ }
7506
+ function stepAt(nowMs) {
7507
+ return Math.floor(nowMs / 1e3 / STEP_SECONDS);
7508
+ }
7509
+ function safeEqual(a, b) {
7510
+ const x = Buffer.from(a);
7511
+ const y = Buffer.from(b);
7512
+ return x.length === y.length && timingSafeEqual(x, y);
7513
+ }
7514
+ function matchStep(key2, code, nowMs, skew = 1, digits = 6) {
7515
+ if (!new RegExp(`^\\d{${digits}}$`).test(code)) return void 0;
7516
+ const now = stepAt(nowMs);
7517
+ let matched;
7518
+ for (let step = now - skew; step <= now + skew; step++) {
7519
+ if (safeEqual(hotp(key2, step, digits), code)) matched = step;
7520
+ }
7521
+ return matched;
7522
+ }
7523
+
7524
+ // src/otp/policy.ts
7525
+ var OTP_STATUS = "otp_status";
7526
+ var OTP_ARG = "otp";
7527
+ var DEFAULT_GATED = /* @__PURE__ */ new Set([
7528
+ "create_repository",
7529
+ "fork_repository",
7530
+ "merge_pull_request",
7531
+ "pull_request_review_write",
7532
+ "actions_run_trigger",
7533
+ "label_write",
7534
+ "delete_branch",
7535
+ "dismiss_alert",
7536
+ // Disabled by default too (src/availability.ts); gated once enabled.
7537
+ "create_pull_request_with_copilot",
7538
+ "assign_copilot_to_issue",
7539
+ "assign_copilot_to_issue_with_intent"
7540
+ ]);
7541
+ var OTP_DEFAULTS = {
7542
+ windowSeconds: 300,
7543
+ maxFailures: 5,
7544
+ lockoutSeconds: 600,
7545
+ maxLockouts: 3,
7546
+ statePath: "/var/lib/gh-broker/otp-state.json"
7547
+ };
7548
+ function otpSettings(config2) {
7549
+ const o = config2.otp;
7550
+ return {
7551
+ windowSeconds: o?.windowSeconds ?? OTP_DEFAULTS.windowSeconds,
7552
+ maxFailures: o?.maxFailures ?? OTP_DEFAULTS.maxFailures,
7553
+ lockoutSeconds: o?.lockoutSeconds ?? OTP_DEFAULTS.lockoutSeconds,
7554
+ maxLockouts: o?.maxLockouts ?? OTP_DEFAULTS.maxLockouts,
7555
+ statePath: o?.statePath ?? OTP_DEFAULTS.statePath
7556
+ };
7557
+ }
7558
+ function isGated(rule) {
7559
+ if (rule.name === OTP_STATUS) return false;
7560
+ return rule.otp ?? DEFAULT_GATED.has(rule.name);
7561
+ }
7562
+ function readSeed(config2, secrets) {
7563
+ if (!config2.otp) return { problem: "OTP is not set up on this broker (no config.otp); re-run the installer to set it up" };
7564
+ const name = config2.otp.seed.replace(/^cred:/, "");
7565
+ if (!secrets.has(config2.otp.seed)) return { problem: `OTP is not set up on this broker: its seed credential "${name}" is not stored (re-run the installer to generate it)` };
7566
+ let key2;
7567
+ try {
7568
+ key2 = base32Decode(secrets.resolve(config2.otp.seed).trim());
7569
+ } catch {
7570
+ key2 = Buffer.alloc(0);
7571
+ }
7572
+ if (key2.length === 0) return { problem: `OTP is not set up on this broker: the stored seed "${name}" is not valid base32 (re-run the installer to replace it)` };
7573
+ return { key: key2 };
7574
+ }
7575
+ function otpSetupProblem(config2, secrets) {
7576
+ const seed = readSeed(config2, secrets);
7577
+ return "problem" in seed ? seed.problem : void 0;
7578
+ }
7579
+
7580
+ // src/agents.ts
7581
+ import { isAbsolute as isAbsolute2 } from "node:path";
7582
+ var AGENT_USER = /^[a-z_][a-z0-9_-]{0,15}$/;
7583
+ var RESERVED_AGENT_USERS = ["__proto__", "root", "gh-broker"];
7584
+ function isAgentUser(name) {
7585
+ return AGENT_USER.test(name) && !RESERVED_AGENT_USERS.includes(name);
7586
+ }
7587
+ var AGENT_GROUP_PREFIX = "gh-broker-agent-";
7588
+ var SERVICE_USER = "gh-broker";
7589
+ function agentsDir(listen) {
7590
+ if (!listen.startsWith("agents:")) return void 0;
7591
+ const dir = listen.slice("agents:".length);
7592
+ return isAbsolute2(dir) ? dir : void 0;
7593
+ }
7594
+ function parseAgentGroups(groupFile) {
7595
+ const agents = [];
7596
+ const skipped = [];
7597
+ for (const line of groupFile.split("\n")) {
7598
+ const [group, , gidText, memberText] = line.split(":");
7599
+ if (!group?.startsWith(AGENT_GROUP_PREFIX)) continue;
7600
+ const user = group.slice(AGENT_GROUP_PREFIX.length);
7601
+ const gid = Number(gidText);
7602
+ if (!isAgentUser(user)) {
7603
+ skipped.push(`${group}: "${user}" is not a valid agent user name`);
7604
+ continue;
7605
+ }
7606
+ if (!gidText || !Number.isSafeInteger(gid)) {
7607
+ skipped.push(`${group}: bad gid`);
7608
+ continue;
7609
+ }
7610
+ const members2 = new Set((memberText ?? "").split(",").map((m) => m.trim()).filter(Boolean));
7611
+ if (members2.size !== 2 || !members2.has(SERVICE_USER) || !members2.has(user)) {
7612
+ skipped.push(`${group}: members must be exactly ${SERVICE_USER} and ${user}`);
7613
+ continue;
7614
+ }
7615
+ agents.push({ user, group, gid });
7616
+ }
7617
+ return { agents, skipped };
7618
+ }
7619
+
7620
+ // src/denylist.ts
7621
+ var DENIED_TOOLS = /* @__PURE__ */ new Map([
7622
+ ["create_or_update_file", "file writes go through git"],
7623
+ ["push_files", "file writes go through git"],
7624
+ ["delete_file", "file writes go through git"],
7625
+ ["delete_repository", "deleting a repo stays a manual step in GitHub"],
7626
+ ["create_repository_ruleset", "rulesets are managed by org bootstrap"],
7627
+ ["custom_properties_write", "repo configuration is managed by org bootstrap"]
7628
+ ]);
7629
+ var WRITE_VERBS = "create|update|delete|write|set|add|remove|edit|manage|change";
7630
+ var writeTo = (subjects, verbs = WRITE_VERBS) => new RegExp(`(^|_)(${verbs})_(.*_)?(${subjects})$|(^|_)(${subjects})_(${verbs})$`);
7631
+ var DENIED_PATTERNS = [
7632
+ [writeTo("files?|contents?|commits?|trees?|blobs?|refs?|git_refs?|changes", "create|update|delete|write|push"), "file writes go through git"],
7633
+ [writeTo("repos?|repositor(y|ies)", "delete"), "deleting a repo stays a manual step in GitHub"],
7634
+ // Not `create`: create_repository is a gated tool, not repo configuration.
7635
+ [writeTo("repos?|repositor(y|ies)|repo_settings|repository_settings|settings|visibility|default_branch|topics", "update|edit|write|set|change|manage|replace|transfer|archive|unarchive|rename"), "repo configuration is managed by org bootstrap"],
7636
+ [writeTo("custom_property|custom_properties|custom_property_values?"), "repo configuration is managed by org bootstrap"],
7637
+ [writeTo("security_and_analysis|vulnerability_alerts|automated_security_fixes", `${WRITE_VERBS}|enable|disable`), "repo configuration is managed by org bootstrap"],
7638
+ [writeTo("workflows?", "enable|disable|write|update|delete"), "turning workflows on or off changes how CI behaves; org bootstrap manages it"],
7639
+ [writeTo("rulesets?"), "rulesets are managed by org bootstrap"],
7640
+ [writeTo("branch_protections?|branch_protection_rules?"), "branch protection is managed by org bootstrap"],
7641
+ [writeTo("variables?|secrets?"), "Actions variables and secrets are managed by org bootstrap"],
7642
+ [writeTo("workflow_permissions|actions_permissions"), "workflow permissions are managed by org bootstrap"],
7643
+ [writeTo("webhooks?|hooks?|deploy_keys?|collaborators?|environments?"), "repo access and configuration are managed by org bootstrap"],
7644
+ [/^(bulk|batch)_(close|lock|unlock|delete|update|write|edit|label|set|add|remove)|^close_((all|bulk)_)?(issues|pull_requests|prs)$|_(issues|pull_requests)_close$/, "bulk writes aren't offered; loop over the single-item tool"],
7645
+ [writeTo("issues?|conversations?|pull_requests?|discussions?", "lock|unlock"), "locking conversations isn't offered"],
7646
+ [/^lock_write$/, "locking conversations isn't offered"]
7647
+ ];
7648
+ function deniedReason(name) {
7649
+ const exact = DENIED_TOOLS.get(name);
7650
+ if (exact) return exact;
7651
+ for (const [pattern, why] of DENIED_PATTERNS) if (pattern.test(name)) return why;
7652
+ return void 0;
7653
+ }
7654
+
7476
7655
  // src/config.ts
7477
7656
  var MODES = ["read", "write"];
7478
7657
  function loadConfig(path) {
@@ -7484,8 +7663,11 @@ function loadConfig(path) {
7484
7663
  }
7485
7664
  if (typeof raw !== "object" || raw === null) throw new Error("config: root must be an object");
7486
7665
  const c = raw;
7487
- if (typeof c.listen !== "string" || !c.listen.startsWith("unix:")) {
7488
- throw new Error('config.listen: must be a "unix:<path>" string');
7666
+ if (typeof c.listen === "string" && c.listen.startsWith("unix:")) {
7667
+ throw new Error('config.listen: the shared "unix:" socket was replaced by one socket per agent; re-run the installer (sudo gh-broker install) to set listen to "agents:/run/gh-broker/agents"');
7668
+ }
7669
+ if (typeof c.listen !== "string" || !agentsDir(c.listen)) {
7670
+ throw new Error('config.listen: must be "agents:<absolute directory>", e.g. "agents:/run/gh-broker/agents"');
7489
7671
  }
7490
7672
  if (typeof c.upstream !== "string" || !/^https?:\/\//.test(c.upstream)) {
7491
7673
  throw new Error("config.upstream: must be an http(s) URL");
@@ -7499,11 +7681,20 @@ function loadConfig(path) {
7499
7681
  if (typeof o.name !== "string" || !o.name) throw new Error(`config.tools[${i}].name: required`);
7500
7682
  if (!MODES.includes(o.mode)) throw new Error(`config.tools[${i}].mode: must be read|write`);
7501
7683
  if (seen.has(o.name)) throw new Error(`config.tools: duplicate tool "${o.name}"`);
7684
+ const denied = deniedReason(o.name);
7685
+ if (denied) throw new Error(`config.tools[${i}] ("${o.name}"): never allowed in gh-broker (${denied}); remove it from config.tools`);
7502
7686
  seen.add(o.name);
7503
7687
  const custom2 = o.custom === true;
7504
7688
  const augment = o.augment === true;
7505
7689
  if (custom2 && augment) throw new Error(`config.tools[${i}] ("${o.name}"): custom and augment are mutually exclusive`);
7506
- return { name: o.name, mode: o.mode, custom: custom2, augment };
7690
+ if (o.otp !== void 0 && typeof o.otp !== "boolean") throw new Error(`config.tools[${i}].otp: must be true or false`);
7691
+ if (o.enabled !== void 0 && typeof o.enabled !== "boolean") throw new Error(`config.tools[${i}].enabled: must be true or false`);
7692
+ if (o.name === OTP_STATUS) {
7693
+ if (o.otp === true) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): can't be gated (otp: true)`);
7694
+ if (o.enabled === false) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): can't be disabled (enabled: false)`);
7695
+ if (!custom2) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): must be custom: true`);
7696
+ }
7697
+ return { name: o.name, mode: o.mode, custom: custom2, augment, ...typeof o.otp === "boolean" ? { otp: o.otp } : {}, ...typeof o.enabled === "boolean" ? { enabled: o.enabled } : {} };
7507
7698
  });
7508
7699
  if (typeof c.owners !== "object" || c.owners === null) throw new Error("config.owners: must be an object");
7509
7700
  const owners = {};
@@ -7580,6 +7771,7 @@ function loadConfig(path) {
7580
7771
  ...Array.isArray(n.tokenOrgs) ? { tokenOrgs: n.tokenOrgs } : {}
7581
7772
  };
7582
7773
  }
7774
+ const otp = c.otp !== void 0 ? parseOtpConfig(c.otp) : void 0;
7583
7775
  return {
7584
7776
  listen: c.listen,
7585
7777
  upstream: c.upstream,
@@ -7589,9 +7781,27 @@ function loadConfig(path) {
7589
7781
  ...secrets ? { secrets } : {},
7590
7782
  ...defaultOwner ? { defaultOwner } : {},
7591
7783
  ...npm ? { npm } : {},
7592
- ...c.ghcr !== void 0 ? { ghcr: parseGhcrConfig(c.ghcr) } : {}
7784
+ ...c.ghcr !== void 0 ? { ghcr: parseGhcrConfig(c.ghcr) } : {},
7785
+ ...otp ? { otp } : {}
7593
7786
  };
7594
7787
  }
7788
+ function parseOtpConfig(raw) {
7789
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("config.otp: must be an object");
7790
+ const o = raw;
7791
+ if (typeof o.seed !== "string" || !o.seed.startsWith("cred:")) throw new Error('config.otp.seed: must be a "cred:<name>" ref');
7792
+ const out = { seed: o.seed };
7793
+ for (const key2 of ["windowSeconds", "maxFailures", "lockoutSeconds", "maxLockouts"]) {
7794
+ const v = o[key2];
7795
+ if (v === void 0) continue;
7796
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) throw new Error(`config.otp.${key2}: must be a positive integer`);
7797
+ out[key2] = v;
7798
+ }
7799
+ if (o.statePath !== void 0) {
7800
+ if (typeof o.statePath !== "string" || !isAbsolute3(o.statePath)) throw new Error("config.otp.statePath: must be an absolute path");
7801
+ out.statePath = o.statePath;
7802
+ }
7803
+ return out;
7804
+ }
7595
7805
  function indexTools(tools) {
7596
7806
  return new Map(tools.map((t) => [t.name, t]));
7597
7807
  }
@@ -7655,6 +7865,7 @@ function validateRefs(config2, read) {
7655
7865
  assertSafeName(config2.npm.token.slice(CRED_PREFIX.length));
7656
7866
  }
7657
7867
  for (const ref of ghcrCredRefs(config2.ghcr)) credName(ref);
7868
+ if (config2.otp) credName(config2.otp.seed);
7658
7869
  }
7659
7870
  function systemdCredsStore(config2) {
7660
7871
  const dir = process.env.CREDENTIALS_DIRECTORY;
@@ -9590,9 +9801,9 @@ var asciiTabOrNewline = /[\t\n\r]/g;
9590
9801
  function stripTabAndNewline(value) {
9591
9802
  return value.replace(asciiTabOrNewline, "");
9592
9803
  }
9593
- function urlHostnameOk(url2, hostname) {
9594
- hostname.lastIndex = 0;
9595
- return hostname.test(url2.hostname);
9804
+ function urlHostnameOk(url2, hostname2) {
9805
+ hostname2.lastIndex = 0;
9806
+ return hostname2.test(url2.hostname);
9596
9807
  }
9597
9808
  function urlProtocolOk(url2, protocol) {
9598
9809
  protocol.lastIndex = 0;
@@ -10190,7 +10401,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
10190
10401
  }
10191
10402
  return propValues;
10192
10403
  });
10193
- const isObject3 = isObject2;
10404
+ const isObject4 = isObject2;
10194
10405
  const catchall = def.catchall;
10195
10406
  let value;
10196
10407
  const memo2 = globalConfig.memoizer;
@@ -10198,7 +10409,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
10198
10409
  inst._zod.parse = (payload, ctx) => {
10199
10410
  value ?? (value = _normalized.value);
10200
10411
  const input = payload.value;
10201
- if (!isObject3(input)) {
10412
+ if (!isObject4(input)) {
10202
10413
  payload.issues.push({
10203
10414
  expected: "object",
10204
10415
  code: "invalid_type",
@@ -10334,7 +10545,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
10334
10545
  return doc.compile();
10335
10546
  };
10336
10547
  let fastpass;
10337
- const isObject3 = isObject2;
10548
+ const isObject4 = isObject2;
10338
10549
  const jit = !globalConfig.jitless;
10339
10550
  const allowsEval2 = allowsEval;
10340
10551
  const fastEnabled = jit && allowsEval2.value;
@@ -10343,7 +10554,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
10343
10554
  inst._zod.parse = (payload, ctx) => {
10344
10555
  value ?? (value = _normalized.value);
10345
10556
  const input = payload.value;
10346
- if (!isObject3(input)) {
10557
+ if (!isObject4(input)) {
10347
10558
  payload.issues.push({
10348
10559
  expected: "object",
10349
10560
  code: "invalid_type",
@@ -17798,8 +18009,8 @@ async function random(size) {
17798
18009
  const evenDistCutoff = Math.pow(2, 8) - Math.pow(2, 8) % mask.length;
17799
18010
  let result = "";
17800
18011
  while (result.length < size) {
17801
- const randomBytes = await getRandomValues(size - result.length);
17802
- for (const randomByte of randomBytes) {
18012
+ const randomBytes3 = await getRandomValues(size - result.length);
18013
+ for (const randomByte of randomBytes3) {
17803
18014
  if (randomByte < evenDistCutoff) {
17804
18015
  result += mask[randomByte % mask.length];
17805
18016
  }
@@ -19985,8 +20196,8 @@ var stringifyIteratively = (rootValue, replacer, spaceParam) => {
19985
20196
  const isFunctionReplacer = typeof replacer === "function";
19986
20197
  const propertyList = Array.isArray(replacer) ? new Set(replacer.map(String)) : null;
19987
20198
  const prepareVal = (parent, key2, val) => {
19988
- const isObject3 = val !== null && typeof val === "object";
19989
- const hasToJSON = isObject3 && typeof val.toJSON === "function";
20199
+ const isObject4 = val !== null && typeof val === "object";
20200
+ const hasToJSON = isObject4 && typeof val.toJSON === "function";
19990
20201
  if (hasToJSON) {
19991
20202
  val = val.toJSON(key2);
19992
20203
  }
@@ -20820,7 +21031,14 @@ function audit(entry) {
20820
21031
 
20821
21032
  // src/availability.ts
20822
21033
  var GHCR_TOOLS = /* @__PURE__ */ new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
21034
+ var DEFAULT_DISABLED = /* @__PURE__ */ new Set(["create_pull_request_with_copilot", "assign_copilot_to_issue", "assign_copilot_to_issue_with_intent"]);
21035
+ function isEnabled(rule) {
21036
+ return rule.enabled ?? !DEFAULT_DISABLED.has(rule.name);
21037
+ }
20823
21038
  function unavailableReason(rule, config2, secrets) {
21039
+ if (!isEnabled(rule)) {
21040
+ return DEFAULT_DISABLED.has(rule.name) && rule.enabled === void 0 ? `it is disabled by default; set "enabled": true on its entry in config.tools to turn it on` : `it is disabled in config.tools ("enabled": false)`;
21041
+ }
20824
21042
  if (rule.name === "npm_scaffold") {
20825
21043
  if (!config2.npm) return "the npm integration is not configured (enable it with the installer's --npm)";
20826
21044
  if (!secrets.has(config2.npm.token)) return `its credential "${config2.npm.token.replace(/^cred:/, "")}" is not stored (re-run the installer with --npm)`;
@@ -20832,6 +21050,7 @@ function unavailableReason(rule, config2, secrets) {
20832
21050
  return `no GHCR credential is stored (${refs.map((r) => `"${r.replace(/^cred:/, "")}"`).join(", ")}; re-run the installer to add one)`;
20833
21051
  }
20834
21052
  }
21053
+ if (isGated(rule)) return otpSetupProblem(config2, secrets);
20835
21054
  return void 0;
20836
21055
  }
20837
21056
 
@@ -21249,14 +21468,17 @@ var SEARCH_PAGE_SIZE = 100;
21249
21468
  var SEARCH_MAX_PAGES = 10;
21250
21469
  var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml"];
21251
21470
  var NAME_PATTERN = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
21252
- var DEV_LIST_MAX_PAGES = 20;
21471
+ var DEV_LIST_PAGE_SIZE = 25;
21472
+ var DEV_LIST_MIN_PAGE_SIZE = 5;
21473
+ var DEV_LIST_MAX_PAGES = 80;
21474
+ var TIMEOUT_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
21253
21475
  var DEV_BRANCH = "dev";
21254
21476
  var SEARCH_NOTE = "Candidates come from GitHub code search, which lags recent commits and only indexes default branches; each candidate was verified against its default-branch package.json. A repo that changed very recently can be missing.";
21255
21477
  var DEV_NOTE = " Every `dev` branch that differs from its default branch was also read in full, so dev-trunk repos are included; a `dev` whose root package.json version is below the default branch's is treated as abandoned and skipped (see `abandonedDev`).";
21256
21478
  var NO_DEV_NOTE = " Dev branches were not read, so a dev-trunk repo whose real manifests live on `dev` is missing.";
21257
- var DEV_BRANCHES_QUERY = `query($owner: String!, $after: String) {
21479
+ var DEV_BRANCHES_QUERY = `query($owner: String!, $after: String, $first: Int!) {
21258
21480
  repositoryOwner(login: $owner) {
21259
- repositories(first: 100, after: $after, orderBy: { field: NAME, direction: ASC }) {
21481
+ repositories(first: $first, after: $after, orderBy: { field: NAME, direction: ASC }) {
21260
21482
  pageInfo { hasNextPage endCursor }
21261
21483
  nodes {
21262
21484
  name
@@ -21401,8 +21623,17 @@ async function listDevBranches(octokit, budget, owner2) {
21401
21623
  const repos = [];
21402
21624
  const abandoned = [];
21403
21625
  let after = null;
21626
+ let first = DEV_LIST_PAGE_SIZE;
21404
21627
  for (let page = 1; page <= DEV_LIST_MAX_PAGES; page++) {
21405
- const res = await reqOne(octokit, budget, "POST /graphql", { query: DEV_BRANCHES_QUERY, variables: { owner: owner2, after } });
21628
+ let res;
21629
+ try {
21630
+ res = await reqOne(octokit, budget, "POST /graphql", { query: DEV_BRANCHES_QUERY, variables: { owner: owner2, after, first } });
21631
+ } catch (err) {
21632
+ if (!TIMEOUT_STATUSES.has(err.status)) throw err;
21633
+ if (first === DEV_LIST_MIN_PAGE_SIZE) return { repos, abandoned, complete: false, timedOut: true };
21634
+ first = Math.max(DEV_LIST_MIN_PAGE_SIZE, Math.ceil(first / 2));
21635
+ continue;
21636
+ }
21406
21637
  const conn = res.data?.repositoryOwner?.repositories;
21407
21638
  if (!conn) throw new Error(`report_dependents: listing dev branches failed: ${res.errors?.map((e) => e.message).join("; ") ?? `no owner "${owner2}"`}`);
21408
21639
  for (const r of conn.nodes) {
@@ -21506,8 +21737,9 @@ async function reportDependents(octokit, input, deps = {}) {
21506
21737
  dependents.push(entry);
21507
21738
  }
21508
21739
  if (devBranches) {
21509
- const { repos, abandoned, complete } = await listDevBranches(octokit, budget, owner2);
21740
+ const { repos, abandoned, complete, timedOut } = await listDevBranches(octokit, budget, owner2);
21510
21741
  abandonedDev.push(...abandoned);
21742
+ if (timedOut) truncatedReason ??= "github-timeout";
21511
21743
  if (!complete) truncatedReason ??= "max-repos";
21512
21744
  const room = Math.max(0, maxRepos - Math.min(repoNames.length, maxRepos));
21513
21745
  if (repos.length > room) truncatedReason ??= "max-repos";
@@ -23295,7 +23527,7 @@ function tarballDigests(tarball) {
23295
23527
  var PLACEHOLDER_VERSION = "0.0.0-stage";
23296
23528
  var SCAFFOLD_MARKER = "gh-broker";
23297
23529
  var DEFAULT_WORKFLOW = "publish.yml";
23298
- var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "otp", "workflow"]);
23530
+ var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "npm_otp", "workflow"]);
23299
23531
  var PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
23300
23532
  var NPM_SCAFFOLD_SCHEMA = {
23301
23533
  type: "object",
@@ -23303,10 +23535,10 @@ var NPM_SCAFFOLD_SCHEMA = {
23303
23535
  owner: { type: "string", description: "Repository owner (a configured owner)." },
23304
23536
  repo: { type: "string", description: "Repository whose package.json names the package." },
23305
23537
  package: { type: "string", description: 'Scoped package name, e.g. "@cldmv/new-thing". Must be under a scope in config.npm.scopes.' },
23306
- otp: { type: "string", description: "One-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
23538
+ npm_otp: { type: "string", description: "npm one-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
23307
23539
  workflow: { type: "string", description: `The repo's publish workflow file in .github/workflows. Default "publish.yml".` }
23308
23540
  },
23309
- required: ["owner", "repo", "package", "otp"],
23541
+ required: ["owner", "repo", "package", "npm_otp"],
23310
23542
  additionalProperties: false
23311
23543
  };
23312
23544
  var fail3 = (msg) => {
@@ -23320,7 +23552,7 @@ function validate3(input) {
23320
23552
  if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail3('"owner" must be a GitHub owner name');
23321
23553
  if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail3('"repo" must be a repository name');
23322
23554
  if (typeof a.package !== "string" || a.package.length > 214 || !PACKAGE_NAME.test(a.package)) fail3('"package" must be a lowercase scoped npm package name, e.g. "@cldmv/new-thing"');
23323
- if (typeof a.otp !== "string" || !/^[0-9]{6,8}$/.test(a.otp)) fail3('"otp" must be the 6-8 digit code from the authenticator');
23555
+ if (typeof a.npm_otp !== "string" || !/^[0-9]{6,8}$/.test(a.npm_otp)) fail3('"npm_otp" must be the 6-8 digit npm code from the authenticator');
23324
23556
  if (a.workflow !== void 0 && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
23325
23557
  fail3('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
23326
23558
  }
@@ -23328,7 +23560,7 @@ function validate3(input) {
23328
23560
  owner: a.owner,
23329
23561
  repo: a.repo,
23330
23562
  package: a.package,
23331
- otp: a.otp,
23563
+ npm_otp: a.npm_otp,
23332
23564
  workflow: a.workflow ?? DEFAULT_WORKFLOW
23333
23565
  };
23334
23566
  }
@@ -23439,7 +23671,7 @@ async function npmScaffold(input, deps) {
23439
23671
  const existing = await deps.registry.packument(args.package);
23440
23672
  if (!existing) {
23441
23673
  try {
23442
- await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.otp);
23674
+ await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.npm_otp);
23443
23675
  } catch (err) {
23444
23676
  if (err instanceof NpmOtpRequired) {
23445
23677
  return { ...base, status: "needs_otp", done: [], remaining: ["publish", "trust"], message: "npm refused the one-time password before publishing; nothing was written. Call again with a fresh code." };
@@ -23464,7 +23696,7 @@ async function npmScaffold(input, deps) {
23464
23696
  }
23465
23697
  let out;
23466
23698
  try {
23467
- out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.otp);
23699
+ out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.npm_otp);
23468
23700
  } catch (err) {
23469
23701
  if (err instanceof NpmOtpRequired) {
23470
23702
  return {
@@ -23511,10 +23743,187 @@ async function npmScaffold(input, deps) {
23511
23743
  };
23512
23744
  }
23513
23745
 
23746
+ // src/verbs/minimize_comment.ts
23747
+ var MINIMIZE_CLASSIFIERS = ["RESOLVED", "OUTDATED", "DUPLICATE", "OFF_TOPIC", "SPAM", "ABUSE"];
23748
+ var MINIMIZE = `
23749
+ mutation($id: ID!, $classifier: ReportedContentClassifiers!) {
23750
+ minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
23751
+ minimizedComment { isMinimized minimizedReason }
23752
+ }
23753
+ }`;
23754
+ var UNMINIMIZE = `
23755
+ mutation($id: ID!) {
23756
+ unminimizeComment(input: { subjectId: $id }) {
23757
+ unminimizedComment { isMinimized minimizedReason }
23758
+ }
23759
+ }`;
23760
+ async function nodeIdOf(octokit, input) {
23761
+ if (input.commentId !== void 0) {
23762
+ if (typeof input.commentId !== "string" || !input.commentId.trim()) throw new Error("minimize_comment: commentId must be a comment node id");
23763
+ if (input.commentNumber !== void 0) throw new Error("minimize_comment: pass commentId or commentNumber, not both");
23764
+ return input.commentId;
23765
+ }
23766
+ if (!Number.isSafeInteger(input.commentNumber) || input.commentNumber <= 0) {
23767
+ throw new Error("minimize_comment: pass commentId (node id), or commentNumber (REST id) with type and repo");
23768
+ }
23769
+ if (input.type !== "issue" && input.type !== "review") throw new Error('minimize_comment: commentNumber needs type "issue" (issue or PR conversation comment) or "review" (PR review comment)');
23770
+ if (typeof input.repo !== "string" || !input.repo) throw new Error("minimize_comment: commentNumber needs repo");
23771
+ const route = input.type === "issue" ? "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" : "GET /repos/{owner}/{repo}/pulls/comments/{comment_id}";
23772
+ const res = await octokit.request(route, { owner: input.owner, repo: input.repo, comment_id: input.commentNumber });
23773
+ const nodeId = res.data.node_id;
23774
+ if (typeof nodeId !== "string" || !nodeId) throw new Error(`minimize_comment: GitHub returned no node id for ${input.type} comment ${input.commentNumber}`);
23775
+ return nodeId;
23776
+ }
23777
+ async function minimizeComment(octokit, input) {
23778
+ const classifier = input.classifier ?? "RESOLVED";
23779
+ if (input.unhide === true && input.classifier !== void 0) throw new Error("minimize_comment: unhide takes no classifier");
23780
+ if (!MINIMIZE_CLASSIFIERS.includes(classifier)) throw new Error(`minimize_comment: classifier must be one of ${MINIMIZE_CLASSIFIERS.join(", ")}`);
23781
+ const id = await nodeIdOf(octokit, input);
23782
+ if (input.unhide === true) {
23783
+ const data2 = await octokit.graphql(UNMINIMIZE, { id });
23784
+ const c2 = data2.unminimizeComment?.unminimizedComment;
23785
+ if (!c2) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
23786
+ return { commentId: id, isMinimized: c2.isMinimized, minimizedReason: c2.minimizedReason ?? null };
23787
+ }
23788
+ const data = await octokit.graphql(MINIMIZE, { id, classifier });
23789
+ const c = data.minimizeComment?.minimizedComment;
23790
+ if (!c) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
23791
+ return { commentId: id, isMinimized: c.isMinimized, minimizedReason: c.minimizedReason ?? null };
23792
+ }
23793
+ var MINIMIZE_COMMENT_DESCRIPTION = `Hide a comment on an issue or PR (collapsed, like "Hide \u2192 Resolved" in GitHub), or show it again with unhide: true. Meant for folding away bot review comments once they're addressed. Name the comment by node id (commentId, e.g. from pull_request_read get_review_comments), or by REST id (commentNumber) with type "issue" (issue or PR conversation comment) or "review" (PR review comment) and repo. Runs on the write PAT.`;
23794
+ var MINIMIZE_COMMENT_SCHEMA = {
23795
+ type: "object",
23796
+ required: ["owner"],
23797
+ additionalProperties: false,
23798
+ properties: {
23799
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23800
+ repo: { type: "string", description: "repository name (needed with commentNumber)" },
23801
+ commentId: { type: "string", description: 'comment node id, e.g. "IC_\u2026" or "PRRC_\u2026"' },
23802
+ commentNumber: { type: "number", description: "REST comment id, used with type and repo when the node id isn't at hand" },
23803
+ type: { type: "string", enum: ["issue", "review"], description: "with commentNumber: issue (issue or PR conversation comment) or review (PR review comment)" },
23804
+ classifier: { type: "string", enum: [...MINIMIZE_CLASSIFIERS], description: "why it's hidden (default RESOLVED)" },
23805
+ unhide: { type: "boolean", description: "true shows a hidden comment again" }
23806
+ }
23807
+ };
23808
+
23809
+ // src/verbs/delete_branch.ts
23810
+ var KEPT_BRANCHES = /* @__PURE__ */ new Set(["master", "main", "next", "hotfixes", "dev", "badges", "gh-pages"]);
23811
+ async function deleteBranch(octokit, input) {
23812
+ const { owner: owner2, repo } = input;
23813
+ if (typeof repo !== "string" || !repo) throw new Error("delete_branch: repo is required");
23814
+ const branch = typeof input.branch === "string" ? input.branch.replace(/^refs\/heads\//, "") : "";
23815
+ if (!branch) throw new Error("delete_branch: branch is required");
23816
+ if (KEPT_BRANCHES.has(branch)) throw new Error(`delete_branch: refusing to delete "${branch}": the release flow depends on it`);
23817
+ const meta2 = await octokit.request("GET /repos/{owner}/{repo}", { owner: owner2, repo });
23818
+ const repoData = meta2.data;
23819
+ const defaultBranch = repoData.default_branch;
23820
+ if (branch === defaultBranch) throw new Error(`delete_branch: refusing to delete "${branch}": it is ${owner2}/${repo}'s default branch`);
23821
+ const lookups = [
23822
+ { side: "head", where: { owner: owner2, repo }, filter: { head: `${owner2}:${branch}` } },
23823
+ { side: "base", where: { owner: owner2, repo }, filter: { base: branch } }
23824
+ ];
23825
+ const upstreams = repoData.fork ? [repoData.parent, repoData.source] : [];
23826
+ const seen = /* @__PURE__ */ new Set();
23827
+ for (const up of upstreams) {
23828
+ if (!up?.owner?.login || !up.name || seen.has(`${up.owner.login}/${up.name}`)) continue;
23829
+ seen.add(`${up.owner.login}/${up.name}`);
23830
+ lookups.push({ side: "head", where: { owner: up.owner.login, repo: up.name }, filter: { head: `${owner2}:${branch}` } });
23831
+ }
23832
+ for (const { side, where, filter } of lookups) {
23833
+ const open2 = await octokit.request("GET /repos/{owner}/{repo}/pulls", { ...where, state: "open", ...filter, per_page: 10 });
23834
+ const prs = open2.data;
23835
+ if (prs.length > 0) {
23836
+ const ref2 = where.owner === owner2 && where.repo === repo ? "" : `${where.owner}/${where.repo}`;
23837
+ throw new Error(`delete_branch: refusing to delete "${branch}": it is the ${side} of open PR ${prs.map((p) => `${ref2}#${p.number}`).join(", ")}, which deleting it would close`);
23838
+ }
23839
+ }
23840
+ const missing = new Error(`delete_branch: no branch "${branch}" in ${owner2}/${repo}`);
23841
+ let ref;
23842
+ try {
23843
+ ref = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
23844
+ } catch (err) {
23845
+ throw err.status === 404 ? missing : err;
23846
+ }
23847
+ const sha = ref.data.object?.sha;
23848
+ if (typeof sha !== "string") throw missing;
23849
+ await octokit.request("DELETE /repos/{owner}/{repo}/git/refs/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
23850
+ return { deleted: branch, sha, restore: `git push origin ${sha}:refs/heads/${branch}` };
23851
+ }
23852
+ var DELETE_BRANCH_DESCRIPTION = "Delete one branch on GitHub. Refuses the repo's default branch, the release-flow branches (master, main, next, hotfixes, dev, badges, gh-pages), and any branch that is the head or base of an open PR, including a PR from a fork into its parent or the root of its fork network (deleting it would close the PR). Rulesets still apply. Returns the deleted tip's sha and the git command that restores it. Runs on the write PAT (Contents: write).";
23853
+ var DELETE_BRANCH_SCHEMA = {
23854
+ type: "object",
23855
+ required: ["owner", "repo", "branch"],
23856
+ additionalProperties: false,
23857
+ properties: {
23858
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23859
+ repo: { type: "string", description: "repository name" },
23860
+ branch: { type: "string", description: 'branch name, e.g. "feat/otp-gate" (a leading refs/heads/ is accepted)' }
23861
+ }
23862
+ };
23863
+
23864
+ // src/verbs/dismiss_alert.ts
23865
+ var ALERT_KINDS = ["code_scanning", "dependabot", "secret_scanning"];
23866
+ var DISMISS_REASONS = {
23867
+ code_scanning: ["false positive", "won't fix", "used in tests"],
23868
+ dependabot: ["fix_started", "inaccurate", "no_bandwidth", "not_used", "tolerable_risk"],
23869
+ secret_scanning: ["false_positive", "wont_fix", "revoked", "used_in_tests"]
23870
+ };
23871
+ var ROUTES = {
23872
+ code_scanning: "PATCH /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}",
23873
+ dependabot: "PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number}",
23874
+ secret_scanning: "PATCH /repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}"
23875
+ };
23876
+ function bodyFor(kind, reopen, reason, comment) {
23877
+ if (kind === "secret_scanning") {
23878
+ if (reopen) return { state: "open", ...comment ? { resolution_comment: comment } : {} };
23879
+ return { state: "resolved", resolution: reason, ...comment ? { resolution_comment: comment } : {} };
23880
+ }
23881
+ if (reopen) return { state: "open" };
23882
+ return { state: "dismissed", dismissed_reason: reason, ...comment ? { dismissed_comment: comment } : {} };
23883
+ }
23884
+ async function dismissAlert(octokit, input) {
23885
+ const { owner: owner2, repo, kind } = input;
23886
+ if (typeof repo !== "string" || !repo) throw new Error("dismiss_alert: repo is required");
23887
+ if (!ALERT_KINDS.includes(kind)) throw new Error(`dismiss_alert: kind must be one of ${ALERT_KINDS.join(", ")}`);
23888
+ if (!Number.isSafeInteger(input.alertNumber) || input.alertNumber <= 0) throw new Error("dismiss_alert: alertNumber must be a positive integer");
23889
+ if (input.comment !== void 0 && typeof input.comment !== "string") throw new Error("dismiss_alert: comment must be a string");
23890
+ const reopen = input.reopen === true;
23891
+ if (reopen && input.reason !== void 0) throw new Error("dismiss_alert: a reopen takes no reason");
23892
+ if (reopen && input.comment !== void 0 && kind !== "secret_scanning") throw new Error(`dismiss_alert: GitHub stores no comment when a ${kind} alert is reopened; leave comment out`);
23893
+ if (!reopen && !DISMISS_REASONS[kind].includes(input.reason)) {
23894
+ throw new Error(`dismiss_alert: reason is required for a ${kind} alert, one of: ${DISMISS_REASONS[kind].map((r) => `"${r}"`).join(", ")}`);
23895
+ }
23896
+ const res = await octokit.request(ROUTES[kind], { owner: owner2, repo, alert_number: input.alertNumber, ...bodyFor(kind, reopen, input.reason, input.comment) });
23897
+ const d = res.data;
23898
+ const reason = kind === "secret_scanning" ? d.resolution : d.dismissed_reason;
23899
+ return {
23900
+ kind,
23901
+ number: typeof d.number === "number" ? d.number : input.alertNumber,
23902
+ state: typeof d.state === "string" ? d.state : "unknown",
23903
+ reason: typeof reason === "string" ? reason : null,
23904
+ url: typeof d.html_url === "string" ? d.html_url : null
23905
+ };
23906
+ }
23907
+ var DISMISS_ALERT_DESCRIPTION = `Dismiss one code scanning, Dependabot or secret scanning alert, or reopen it with reopen: true. A dismissal needs a reason, which depends on kind: code_scanning ${DISMISS_REASONS.code_scanning.map((r) => `"${r}"`).join(" / ")}; dependabot ${DISMISS_REASONS.dependabot.join(" / ")}; secret_scanning ${DISMISS_REASONS.secret_scanning.join(" / ")}. Returns the alert's number, state, reason and URL only; a secret's value is never returned. Runs on the write PAT.`;
23908
+ var DISMISS_ALERT_SCHEMA = {
23909
+ type: "object",
23910
+ required: ["owner", "repo", "kind", "alertNumber"],
23911
+ additionalProperties: false,
23912
+ properties: {
23913
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23914
+ repo: { type: "string", description: "repository name" },
23915
+ kind: { type: "string", enum: [...ALERT_KINDS], description: "which alert type" },
23916
+ alertNumber: { type: "number", description: "the alert number" },
23917
+ reason: { type: "string", description: "why it's dismissed; the accepted values depend on kind (see the description)" },
23918
+ comment: { type: "string", description: "optional note stored with the dismissal" },
23919
+ reopen: { type: "boolean", description: "true reopens the alert instead of dismissing it (no reason)" }
23920
+ }
23921
+ };
23922
+
23514
23923
  // src/verbs/ghcr/archive.ts
23515
23924
  import { closeSync, constants, fstatSync, openSync, readlinkSync, realpathSync } from "node:fs";
23516
23925
  import { createHash as createHash2 } from "node:crypto";
23517
- import { dirname, isAbsolute as isAbsolute2, join as join3, normalize as normalize2 } from "node:path";
23926
+ import { dirname, isAbsolute as isAbsolute4, join as join3, normalize as normalize2 } from "node:path";
23518
23927
 
23519
23928
  // src/verbs/ghcr/tar.ts
23520
23929
  import { readSync } from "node:fs";
@@ -23685,7 +24094,7 @@ var fail5 = (msg) => {
23685
24094
  };
23686
24095
  function openArchive(input, dropDir, maxBytes) {
23687
24096
  if (typeof input !== "string" || !input || input.length > 4096 || input.includes("\0")) fail5('"archive" must be a file path inside the drop directory');
23688
- const candidate = normalize2(isAbsolute2(input) ? input : join3(dropDir, input));
24097
+ const candidate = normalize2(isAbsolute4(input) ? input : join3(dropDir, input));
23689
24098
  const outside = `"archive" must be an existing file inside the drop directory ${dropDir}`;
23690
24099
  if (!candidate.startsWith(`${dropDir}/`)) fail5(outside);
23691
24100
  let realDrop;
@@ -24400,6 +24809,349 @@ var GHCR_CATALOG = {
24400
24809
  }
24401
24810
  };
24402
24811
 
24812
+ // src/otp/gate.ts
24813
+ import { createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
24814
+ import { hostname } from "node:os";
24815
+
24816
+ // src/otp/state.ts
24817
+ import { closeSync as closeSync2, fstatSync as fstatSync2, fsyncSync, openSync as openSync2, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
24818
+ import { randomBytes } from "node:crypto";
24819
+ import { dirname as dirname2 } from "node:path";
24820
+ function emptyState() {
24821
+ return { version: 1, lastAcceptedStep: 0, users: {} };
24822
+ }
24823
+ function emptyUser() {
24824
+ return { failures: 0, lockedUntil: null, lockouts: [], hardLock: null };
24825
+ }
24826
+ function userState(state, user) {
24827
+ const u = Object.hasOwn(state.users, user) ? state.users[user] : emptyUser();
24828
+ return { ...u, lockouts: [...u.lockouts] };
24829
+ }
24830
+ var isIso = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
24831
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
24832
+ var isObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24833
+ function parseUser(raw) {
24834
+ if (!isObject3(raw) || !isCount(raw.failures)) return void 0;
24835
+ if (raw.lockedUntil !== null && !isIso(raw.lockedUntil)) return void 0;
24836
+ if (!Array.isArray(raw.lockouts) || !raw.lockouts.every(isIso)) return void 0;
24837
+ let hardLock = null;
24838
+ if (raw.hardLock !== null) {
24839
+ const h = raw.hardLock;
24840
+ if (!isObject3(h) || !isIso(h.since) || !isCount(h.lockouts)) return void 0;
24841
+ hardLock = { since: h.since, lockouts: h.lockouts };
24842
+ }
24843
+ return { failures: raw.failures, lockedUntil: raw.lockedUntil, lockouts: [...raw.lockouts], hardLock };
24844
+ }
24845
+ function parseState(raw) {
24846
+ if (!isObject3(raw) || raw.version !== 1 || !isCount(raw.lastAcceptedStep) || !isObject3(raw.users)) return void 0;
24847
+ const users = {};
24848
+ for (const [name, entry] of Object.entries(raw.users)) {
24849
+ const u = parseUser(entry);
24850
+ if (!u || !isAgentUser(name)) return void 0;
24851
+ users[name] = u;
24852
+ }
24853
+ return { version: 1, lastAcceptedStep: raw.lastAcceptedStep, users };
24854
+ }
24855
+ function signature(path) {
24856
+ try {
24857
+ const st = statSync(path);
24858
+ return `${st.ino}:${st.mtimeMs}:${st.size}`;
24859
+ } catch (err) {
24860
+ if (err.code === "ENOENT") return "missing";
24861
+ return `error:${err.code}`;
24862
+ }
24863
+ }
24864
+ var OtpStateFile = class {
24865
+ path;
24866
+ now;
24867
+ sig;
24868
+ cached;
24869
+ constructor(path, now = Date.now) {
24870
+ this.path = path;
24871
+ this.now = now;
24872
+ }
24873
+ /** The current state, re-read from disk only when the file changed. */
24874
+ read() {
24875
+ const sig = signature(this.path);
24876
+ if (this.cached && sig === this.sig) return this.cached;
24877
+ this.sig = sig;
24878
+ this.cached = this.load(sig);
24879
+ return this.cached;
24880
+ }
24881
+ load(sig) {
24882
+ if (sig === "missing") return { ok: true, state: emptyState() };
24883
+ let state;
24884
+ let why = "unexpected shape";
24885
+ try {
24886
+ state = parseState(JSON.parse(readFileSync3(this.path, "utf8")));
24887
+ } catch (err) {
24888
+ why = err.message;
24889
+ }
24890
+ if (state) return { ok: true, state };
24891
+ const since = new Date(this.now()).toISOString();
24892
+ console.error(`gh-broker: OTP state file ${this.path} is unreadable or corrupt (${why}); failing closed: every tool except otp_status is locked for every user until "gh-broker otp unlock" rewrites it`);
24893
+ return { ok: false, since };
24894
+ }
24895
+ /**
24896
+ * Persist `state` atomically. On success the cache holds the new state; on failure the
24897
+ * cache still holds it (so in-process counters keep counting) and the error is thrown,
24898
+ * so the caller can refuse anything that depends on the write having landed.
24899
+ */
24900
+ write(state) {
24901
+ this.cached = { ok: true, state };
24902
+ const tmp = `${this.path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
24903
+ let written;
24904
+ try {
24905
+ const fd = openSync2(tmp, "wx", 384);
24906
+ try {
24907
+ writeSync(fd, `${JSON.stringify(state, null, " ")}
24908
+ `);
24909
+ fsyncSync(fd);
24910
+ const st = fstatSync2(fd);
24911
+ written = `${st.ino}:${st.mtimeMs}:${st.size}`;
24912
+ } finally {
24913
+ closeSync2(fd);
24914
+ }
24915
+ renameSync(tmp, this.path);
24916
+ } catch (err) {
24917
+ try {
24918
+ unlinkSync(tmp);
24919
+ } catch {
24920
+ }
24921
+ this.sig = signature(this.path);
24922
+ throw new Error(`OTP state could not be saved to ${this.path}: ${err.message}`);
24923
+ }
24924
+ this.sig = written;
24925
+ syncDir(dirname2(this.path));
24926
+ }
24927
+ };
24928
+ function syncDir(dir) {
24929
+ try {
24930
+ const fd = openSync2(dir, "r");
24931
+ try {
24932
+ fsyncSync(fd);
24933
+ } finally {
24934
+ closeSync2(fd);
24935
+ }
24936
+ } catch {
24937
+ }
24938
+ }
24939
+
24940
+ // src/otp/gate.ts
24941
+ var OtpRefusal = class extends Error {
24942
+ audit;
24943
+ constructor(message, audit2) {
24944
+ super(message);
24945
+ this.name = "OtpRefusal";
24946
+ this.audit = audit2;
24947
+ }
24948
+ };
24949
+ var UNLOCK_COMMAND = "sudo gh-broker otp unlock";
24950
+ var DAY_MS = 24 * 60 * 60 * 1e3;
24951
+ var UNLOCK_ALL_COMMAND = "sudo gh-broker otp unlock --all";
24952
+ function unlockCommand(user) {
24953
+ return `${UNLOCK_COMMAND} ${user}`;
24954
+ }
24955
+ var OtpGate = class {
24956
+ config;
24957
+ secrets;
24958
+ settings;
24959
+ now;
24960
+ host;
24961
+ state;
24962
+ /** Per-process key for window ids. */
24963
+ idKey = randomBytes2(32);
24964
+ windows = [];
24965
+ /** Users whose latest bad-code count is only in memory because the state file couldn't be written. */
24966
+ unsaved = /* @__PURE__ */ new Set();
24967
+ constructor(config2, secrets, options = {}) {
24968
+ this.config = config2;
24969
+ this.secrets = secrets;
24970
+ this.settings = otpSettings(config2);
24971
+ this.now = options.now ?? Date.now;
24972
+ this.host = options.host ?? hostname();
24973
+ this.state = new OtpStateFile(this.settings.statePath, this.now);
24974
+ }
24975
+ /** Whether the gate runs at all. Without config.otp no state file is ever touched. */
24976
+ get enabled() {
24977
+ return this.config.otp !== void 0;
24978
+ }
24979
+ /**
24980
+ * The hard-lock refusal for `user`, if that user is hard-locked (or the state file is
24981
+ * unusable, which locks everyone). The router checks this on every call except otp_status.
24982
+ */
24983
+ hardLockRefusal(user) {
24984
+ if (!this.enabled) return void 0;
24985
+ const read = this.state.read();
24986
+ if (!read.ok) {
24987
+ return new OtpRefusal(
24988
+ `gh-broker is locked for every agent: its OTP state file is unreadable or corrupt (since ${read.since}). Ask the user to run "${UNLOCK_ALL_COMMAND}" on ${this.host}. Nothing was run.`,
24989
+ { otp: "hard-locked" }
24990
+ );
24991
+ }
24992
+ const since = userState(read.state, user).hardLock?.since;
24993
+ if (since === void 0) return void 0;
24994
+ return new OtpRefusal(
24995
+ `gh-broker is locked for ${user} after repeated bad OTP codes (since ${since}). Ask the user to run "${unlockCommand(user)}" on ${this.host}. Nothing was run.`,
24996
+ { otp: "hard-locked" }
24997
+ );
24998
+ }
24999
+ /**
25000
+ * Decide `user`'s call to the gated tool `tool` carrying `code` (the raw `otp` argument).
25001
+ * Returns the audit fields when the call may run; throws OtpRefusal when it may not.
25002
+ * Only call this after hardLockRefusal() and the availability check passed (OTP is set up).
25003
+ */
25004
+ authorize(tool, code, user) {
25005
+ const now = this.now();
25006
+ const read = this.state.read();
25007
+ if (!read.ok || userState(read.state, user).hardLock) throw this.hardLockRefusal(user);
25008
+ const state = read.state;
25009
+ const mine = userState(state, user);
25010
+ const lockedUntil = mine.lockedUntil ? Date.parse(mine.lockedUntil) : 0;
25011
+ if (lockedUntil > now) {
25012
+ const minutes = Math.max(1, Math.ceil((lockedUntil - now) / 6e4));
25013
+ throw new OtpRefusal(`OTP is locked for ${minutes} more minute(s) after too many bad codes. Nothing was run.`, { otp: "locked" });
25014
+ }
25015
+ const unsavedRefusal = () => new OtpRefusal(`OTP could not be checked: the broker could not save its OTP state. Nothing was run.`, { otp: "refused" });
25016
+ if (this.unsaved.has(user)) {
25017
+ try {
25018
+ this.state.write(this.compact(state, now));
25019
+ this.unsaved.delete(user);
25020
+ } catch (err) {
25021
+ console.error(`gh-broker: ${err.message}`);
25022
+ throw unsavedRefusal();
25023
+ }
25024
+ }
25025
+ const refusal = () => new OtpRefusal(`OTP required for ${tool}: ask the user for a current code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`, { otp: "refused" });
25026
+ if (code === void 0 || code === null || code === "") throw refusal();
25027
+ if (typeof code === "number" && Number.isInteger(code) && code >= 0 && code <= 999999) code = String(code).padStart(6, "0");
25028
+ if (typeof code !== "string") throw refusal();
25029
+ this.windows = this.windows.filter((w) => w.expiresAt > now);
25030
+ let hit;
25031
+ for (const w of this.windows) if (w.user === user && safeEqual(w.code, code)) hit = w;
25032
+ if (hit) return { otp: "window", window: hit.id };
25033
+ const step = matchStep(readSeed(this.config, this.secrets).key, code, now);
25034
+ if (step !== void 0 && step > state.lastAcceptedStep) {
25035
+ try {
25036
+ this.state.write(this.compact({ ...state, lastAcceptedStep: step, users: { ...state.users, [user]: { ...mine, failures: 0 } } }, now));
25037
+ } catch (err) {
25038
+ console.error(`gh-broker: ${err.message}`);
25039
+ throw unsavedRefusal();
25040
+ }
25041
+ const id = createHmac2("sha256", this.idKey).update(`${user}\0${code}`).digest("hex").slice(0, 16);
25042
+ const expiresAt = now + this.settings.windowSeconds * 1e3;
25043
+ this.windows.push({ user, code, expiresAt, id });
25044
+ return { otp: "opened", window: id, until: new Date(expiresAt).toISOString() };
25045
+ }
25046
+ if (step !== void 0) {
25047
+ throw new OtpRefusal(
25048
+ `OTP code for ${tool} is spent (it, or a newer code, was already used): ask the user for the next code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`,
25049
+ { otp: "refused" }
25050
+ );
25051
+ }
25052
+ throw this.rejection(tool, user, this.recordFailure(tool, state, mine, user, now));
25053
+ }
25054
+ /** The refusal for a wrong code, saying how close `user` now is to a lockout. */
25055
+ rejection(tool, user, after) {
25056
+ const lead = `OTP code for ${tool} was not accepted`;
25057
+ if (after.hardLock) {
25058
+ return new OtpRefusal(`${lead}, and that was the last lockout allowed: gh-broker is now locked for ${user}. Ask the user to run "${unlockCommand(user)}" on ${this.host}. Nothing was run.`, {
25059
+ otp: "refused"
25060
+ });
25061
+ }
25062
+ const lockout = windowText(this.settings.lockoutSeconds);
25063
+ if (after.failures === 0) return new OtpRefusal(`${lead}, and that was too many bad codes: OTP is locked for ${lockout}. Nothing was run.`, { otp: "refused" });
25064
+ return new OtpRefusal(
25065
+ `${lead} (${after.failures} of ${this.settings.maxFailures} bad codes before a lockout of ${lockout}): ask the user for a fresh code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`,
25066
+ { otp: "refused" }
25067
+ );
25068
+ }
25069
+ /** Count one bad code for `user`, starting a short lockout (and maybe the hard lock) at the limit. Returns the user's new counters. */
25070
+ recordFailure(tool, state, mine, user, now) {
25071
+ const next = { ...mine, lockouts: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS) };
25072
+ next.failures += 1;
25073
+ if (next.failures >= this.settings.maxFailures) {
25074
+ const at = new Date(now).toISOString();
25075
+ next.failures = 0;
25076
+ next.lockedUntil = new Date(now + this.settings.lockoutSeconds * 1e3).toISOString();
25077
+ next.lockouts.push(at);
25078
+ if (next.lockouts.length >= this.settings.maxLockouts) next.hardLock = { since: at, lockouts: next.lockouts.length };
25079
+ audit(
25080
+ next.hardLock ? { tool, user, decision: "deny", otp: "hard-lock", detail: `${next.lockouts.length} lockouts in 24 h; every tool except otp_status is refused until "${unlockCommand(user)}"` } : { tool, user, decision: "deny", otp: "lockout", until: next.lockedUntil, detail: `${this.settings.maxFailures} bad codes` }
25081
+ );
25082
+ }
25083
+ try {
25084
+ this.state.write(this.compact({ ...state, users: { ...state.users, [user]: next } }, now));
25085
+ } catch (err) {
25086
+ this.unsaved.add(user);
25087
+ console.error(`gh-broker: ${err.message}`);
25088
+ }
25089
+ return next;
25090
+ }
25091
+ /**
25092
+ * `state` tidied for a write: every user's lockouts pruned to the last 24 h, expired short
25093
+ * lockouts cleared, and users left with nothing to record dropped.
25094
+ */
25095
+ compact(state, now) {
25096
+ const users = {};
25097
+ for (const [name, u] of Object.entries(state.users)) {
25098
+ const lockouts = u.lockouts.filter((t) => Date.parse(t) > now - DAY_MS);
25099
+ const lockedUntil = u.lockedUntil && Date.parse(u.lockedUntil) > now ? u.lockedUntil : null;
25100
+ if (u.failures === 0 && lockedUntil === null && lockouts.length === 0 && u.hardLock === null) continue;
25101
+ users[name] = { failures: u.failures, lockedUntil, lockouts, hardLock: u.hardLock };
25102
+ }
25103
+ return { ...state, users };
25104
+ }
25105
+ /**
25106
+ * The otp_status report for `user`: their own windows and counters, plus every hard-locked
25107
+ * user. Never a code, the seed, or anything derived from them.
25108
+ */
25109
+ status(user) {
25110
+ const now = this.now();
25111
+ const gatedTools = this.config.tools.filter((t) => isGated(t) && isEnabled(t)).map((t) => t.name);
25112
+ let mine = emptyUser();
25113
+ let hardLock = null;
25114
+ let lockedUsers = [];
25115
+ let corrupt = false;
25116
+ if (this.enabled) {
25117
+ const read = this.state.read();
25118
+ if (read.ok) {
25119
+ mine = userState(read.state, user);
25120
+ hardLock = mine.hardLock;
25121
+ lockedUsers = Object.entries(read.state.users).filter(([, u]) => u.hardLock).map(([name, u]) => ({ user: name, since: u.hardLock.since, lockouts: u.hardLock.lockouts, unlockCommand: unlockCommand(name) }));
25122
+ } else {
25123
+ hardLock = { since: read.since, lockouts: 0 };
25124
+ corrupt = true;
25125
+ lockedUsers = [{ user, since: read.since, lockouts: 0, unlockCommand: UNLOCK_ALL_COMMAND }];
25126
+ }
25127
+ }
25128
+ const open2 = this.windows.filter((w) => w.user === user && w.expiresAt > now);
25129
+ const next = open2.length ? Math.min(...open2.map((w) => w.expiresAt)) : void 0;
25130
+ const lockedUntil = mine.lockedUntil && Date.parse(mine.lockedUntil) > now ? mine.lockedUntil : null;
25131
+ return {
25132
+ configured: !("problem" in readSeed(this.config, this.secrets)),
25133
+ user,
25134
+ gatedTools,
25135
+ windowSeconds: this.settings.windowSeconds,
25136
+ openWindows: open2.length,
25137
+ nextWindowExpiresAt: next === void 0 ? null : new Date(next).toISOString(),
25138
+ failures: mine.failures,
25139
+ maxFailures: this.settings.maxFailures,
25140
+ lockedUntil,
25141
+ lockoutsLast24h: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS).length,
25142
+ maxLockouts: this.settings.maxLockouts,
25143
+ hardLock,
25144
+ unlockCommand: corrupt ? UNLOCK_ALL_COMMAND : unlockCommand(user),
25145
+ lockedUsers,
25146
+ host: this.host
25147
+ };
25148
+ }
25149
+ };
25150
+ function windowText(seconds) {
25151
+ if (seconds % 60 === 0) return `${seconds / 60} minute${seconds === 60 ? "" : "s"}`;
25152
+ return `${seconds} seconds`;
25153
+ }
25154
+
24403
25155
  // src/router.ts
24404
25156
  var GHCR_TOOLS2 = /* @__PURE__ */ new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
24405
25157
  var Router = class {
@@ -24410,65 +25162,110 @@ var Router = class {
24410
25162
  rules;
24411
25163
  /** GHCR verbs run on config.ghcr's own credential, not the owner's GitHub PAT (created on first use). */
24412
25164
  ghcr;
24413
- constructor(config2, secrets, upstream, appAuth) {
25165
+ /** The OTP gate (#143). Inert without config.otp. */
25166
+ otp;
25167
+ constructor(config2, secrets, upstream, appAuth, otp) {
24414
25168
  this.config = config2;
24415
25169
  this.secrets = secrets;
24416
25170
  this.upstream = upstream;
24417
25171
  this.appAuth = appAuth;
24418
25172
  this.rules = indexTools(config2.tools);
25173
+ this.otp = otp ?? new OtpGate(config2, secrets);
24419
25174
  }
24420
25175
  /** Tools advertised to the agent (used to build tools/list). */
24421
25176
  advertisedTools() {
24422
25177
  return this.config.tools.filter((rule) => !unavailableReason(rule, this.config, this.secrets));
24423
25178
  }
24424
- async call(tool, args) {
25179
+ /** Run `tool` for `caller`, the agent user whose socket the call came in on. */
25180
+ async call(tool, rawArgs, caller) {
25181
+ const user = caller.user;
24425
25182
  const rule = this.rules.get(tool);
24426
25183
  if (!rule) {
24427
- audit({ tool, decision: "deny", detail: "not in allowlist" });
25184
+ audit({ tool, user, decision: "deny", detail: "not in allowlist" });
24428
25185
  throw new Error(`tool "${tool}" is not allowed`);
24429
25186
  }
25187
+ if (tool !== OTP_STATUS) {
25188
+ const locked = this.otp.hardLockRefusal(user);
25189
+ if (locked) {
25190
+ audit({ tool, user, decision: "deny", detail: "otp hard lock", ...locked.audit });
25191
+ throw locked;
25192
+ }
25193
+ }
25194
+ let args = rawArgs;
25195
+ let code;
25196
+ if (Object.hasOwn(rawArgs, OTP_ARG)) {
25197
+ const { [OTP_ARG]: given, ...rest } = rawArgs;
25198
+ code = given;
25199
+ args = rest;
25200
+ }
24430
25201
  const unavailable = unavailableReason(rule, this.config, this.secrets);
24431
25202
  if (unavailable) {
24432
- audit({ tool, decision: "deny", detail: `unavailable: ${unavailable}` });
25203
+ audit({ tool, user, decision: "deny", detail: `unavailable: ${unavailable}` });
24433
25204
  throw new Error(`tool "${tool}" is not available on this broker: ${unavailable}`);
24434
25205
  }
24435
- if (GHCR_TOOLS2.has(tool)) return this.callGhcr(tool, args, rule);
24436
- let owner2 = extractOwner(args);
25206
+ if (tool === OTP_STATUS) {
25207
+ audit({ tool, user, mode: rule.mode, decision: "custom", ok: true });
25208
+ return { content: [{ type: "text", text: JSON.stringify(this.otp.status(user)) }] };
25209
+ }
25210
+ let createdIn;
25211
+ if (REPO_CREATING.has(tool) || tool === "actions_run_trigger") {
25212
+ try {
25213
+ if (tool === "actions_run_trigger") refuseLogDeletion(args);
25214
+ else createdIn = creationOwner(tool, args, this.config.owners);
25215
+ } catch (err) {
25216
+ const requested = typeof args.organization === "string" ? args.organization : extractOwner(args);
25217
+ audit({ tool, user, mode: rule.mode, ...requested ? { owner: requested.toLowerCase() } : {}, decision: "error", detail: err.message });
25218
+ throw err;
25219
+ }
25220
+ }
25221
+ let otp;
25222
+ if (isGated(rule)) {
25223
+ try {
25224
+ otp = this.otp.authorize(tool, code, user);
25225
+ } catch (err) {
25226
+ audit({ tool, user, decision: "deny", detail: "otp", ...err.audit });
25227
+ throw err;
25228
+ }
25229
+ }
25230
+ if (GHCR_TOOLS2.has(tool)) return this.callGhcr(tool, args, rule, user, otp);
25231
+ let owner2 = createdIn ?? extractOwner(args);
24437
25232
  if (!owner2 && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
24438
25233
  owner2 = this.config.defaultOwner.toLowerCase();
24439
25234
  }
24440
25235
  if (!owner2) {
24441
- audit({ tool, decision: "error", detail: "no owner in args" });
25236
+ audit({ tool, user, decision: "error", detail: "no owner in args" });
24442
25237
  throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
24443
25238
  }
24444
25239
  const creds = this.config.owners[owner2];
24445
25240
  if (!creds) {
24446
- audit({ tool, owner: owner2, decision: "error", detail: "no creds for owner" });
25241
+ audit({ tool, user, owner: owner2, decision: "error", detail: "no creds for owner" });
24447
25242
  throw new Error(`no credentials configured for owner "${owner2}" \u2014 add it to config.owners + creds`);
24448
25243
  }
24449
25244
  const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
24450
- const repo = typeof args.repo === "string" ? `${owner2}/${args.repo}` : owner2;
25245
+ const repoName = tool === "create_repository" ? args.name : args.repo;
25246
+ const repo = typeof repoName === "string" ? `${owner2}/${repoName}` : owner2;
25247
+ const forked = tool === "fork_repository" ? { source: `${String(args.owner)}/${String(args.repo)}` } : {};
24451
25248
  const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
24452
25249
  try {
24453
25250
  const out = rule.custom ? await this.dispatchCustom(tool, args, owner2, token) : rule.augment ? await this.dispatchAugmented(tool, args, owner2, token) : await this.upstream.forward(tool, args, token);
24454
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision, ok: true });
25251
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: true, ...otp });
24455
25252
  return out;
24456
25253
  } catch (err) {
24457
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision, ok: false, detail: err.message });
25254
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: false, detail: err.message, ...otp });
24458
25255
  throw err;
24459
25256
  }
24460
25257
  }
24461
25258
  /** GHCR verbs: config.ghcr checks the owner and image itself; no GitHub PAT is resolved. */
24462
- async callGhcr(tool, args, rule) {
25259
+ async callGhcr(tool, args, rule, user, otp) {
24463
25260
  const owner2 = extractOwner(args) ?? "";
24464
25261
  const repo = typeof args.image === "string" ? `${owner2}/${args.image}` : owner2;
24465
25262
  try {
24466
25263
  this.ghcr ??= new GhcrService(this.config.ghcr, this.secrets);
24467
25264
  const out = tool === "ghcr_push" ? await this.ghcr.push(args) : tool === "ghcr_tags" ? await this.ghcr.tags(args) : this.ghcr.status(args);
24468
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: true });
25265
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: true, ...otp });
24469
25266
  return { content: [{ type: "text", text: JSON.stringify(out) }] };
24470
25267
  } catch (err) {
24471
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message });
25268
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message, ...otp });
24472
25269
  throw err;
24473
25270
  }
24474
25271
  }
@@ -24543,6 +25340,18 @@ var Router = class {
24543
25340
  const out = await npmScaffold(args, { octokit, registry: registry2, scopes: npm.scopes });
24544
25341
  return { content: [{ type: "text", text: JSON.stringify(out) }] };
24545
25342
  }
25343
+ case "minimize_comment": {
25344
+ const out = await minimizeComment(new Octokit({ auth: token }), args);
25345
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25346
+ }
25347
+ case "delete_branch": {
25348
+ const out = await deleteBranch(new Octokit({ auth: token }), args);
25349
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25350
+ }
25351
+ case "dismiss_alert": {
25352
+ const out = await dismissAlert(new Octokit({ auth: token }), args);
25353
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25354
+ }
24546
25355
  default:
24547
25356
  throw new Error(`custom verb "${tool}" not implemented`);
24548
25357
  }
@@ -24649,6 +25458,21 @@ var Router = class {
24649
25458
  }
24650
25459
  }
24651
25460
  };
25461
+ var REPO_CREATING = /* @__PURE__ */ new Set(["create_repository", "fork_repository"]);
25462
+ function creationOwner(tool, args, owners) {
25463
+ if (typeof args.organization !== "string" || !args.organization) {
25464
+ throw new Error(`tool "${tool}": "organization" is required (the org the repo is created in); gh-broker doesn't create repos in a personal account`);
25465
+ }
25466
+ const org = args.organization.toLowerCase();
25467
+ if (!owners[org]) throw new Error(`no credentials configured for owner "${org}" \u2014 add it to config.owners + creds`);
25468
+ if (tool === "create_repository" && typeof args.private !== "boolean") {
25469
+ throw new Error(`tool "create_repository": "private" is required, true or false; a repo's visibility is never left to a default`);
25470
+ }
25471
+ return org;
25472
+ }
25473
+ function refuseLogDeletion(args) {
25474
+ if (args.method === "delete_workflow_run_logs") throw new Error(`tool "actions_run_trigger": method "delete_workflow_run_logs" is not allowed in gh-broker; a run's logs are kept`);
25475
+ }
24652
25476
  function extractOwner(args) {
24653
25477
  if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
24654
25478
  if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
@@ -26016,6 +26840,14 @@ var AppAuth = class {
26016
26840
  }
26017
26841
  };
26018
26842
 
26843
+ // src/verbs/otp_status.ts
26844
+ var OTP_STATUS_SCHEMA = {
26845
+ type: "object",
26846
+ additionalProperties: false,
26847
+ properties: {}
26848
+ };
26849
+ var OTP_STATUS_DESCRIPTION = `Report the broker's OTP gate: whether OTP is set up, which tools need an "otp" code, how many windows are open and when the next one closes, bad codes so far, any short lockout, short lockouts in the last 24 h, and the hard lock (which blocks every other tool) with the command that clears it. Never shows a code or the seed. Works even while the broker is locked. Needs no owner.`;
26850
+
26019
26851
  // src/catalog.ts
26020
26852
  async function buildCatalog(config2, secrets, upstream) {
26021
26853
  const upstreamByName = /* @__PURE__ */ new Map();
@@ -26064,7 +26896,7 @@ async function buildCatalog(config2, secrets, upstream) {
26064
26896
  } else if (rule.name === "npm_scaffold") {
26065
26897
  tools.push({
26066
26898
  name: "npm_scaffold",
26067
- description: "Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next code and call again to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
26899
+ description: "Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, passed as npm_otp, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next npm code and call again with it in npm_otp to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
26068
26900
  inputSchema: NPM_SCAFFOLD_SCHEMA
26069
26901
  });
26070
26902
  } else if (rule.name === "report_unlabeled") {
@@ -26109,6 +26941,14 @@ async function buildCatalog(config2, secrets, upstream) {
26109
26941
  description: "Report CI health for a PR, or every open PR in a repo, at a chosen verbosity: 0 counts only, 1 adds which run/job failed, 2 adds which step failed, 3 adds a tail of the failing job's log. Detail beyond tier 0 is only fetched for runs that are actually failing. Runs on the read PAT.",
26110
26942
  inputSchema: REPORT_CI_STATUS_SCHEMA
26111
26943
  });
26944
+ } else if (rule.name === "minimize_comment") {
26945
+ tools.push({ name: "minimize_comment", description: MINIMIZE_COMMENT_DESCRIPTION, inputSchema: MINIMIZE_COMMENT_SCHEMA });
26946
+ } else if (rule.name === "delete_branch") {
26947
+ tools.push({ name: "delete_branch", description: DELETE_BRANCH_DESCRIPTION, inputSchema: DELETE_BRANCH_SCHEMA });
26948
+ } else if (rule.name === "dismiss_alert") {
26949
+ tools.push({ name: "dismiss_alert", description: DISMISS_ALERT_DESCRIPTION, inputSchema: DISMISS_ALERT_SCHEMA });
26950
+ } else if (rule.name === OTP_STATUS) {
26951
+ tools.push({ name: OTP_STATUS, description: OTP_STATUS_DESCRIPTION, inputSchema: OTP_STATUS_SCHEMA });
26112
26952
  } else if (GHCR_CATALOG[rule.name]) {
26113
26953
  tools.push({ name: rule.name, ...GHCR_CATALOG[rule.name] });
26114
26954
  }
@@ -26154,6 +26994,21 @@ async function buildCatalog(config2, secrets, upstream) {
26154
26994
  });
26155
26995
  continue;
26156
26996
  }
26997
+ if (rule.name === "create_repository" || rule.name === "fork_repository") {
26998
+ const schema = base.inputSchema;
26999
+ const needed = rule.name === "create_repository" ? ["organization", "private"] : ["organization"];
27000
+ const note = rule.name === "create_repository" ? ' gh-broker: "organization" and "private" are required; the repo is never created in a personal account or left public by default.' : ' gh-broker: "organization" (where the fork goes) is required; forks are never created in a personal account.';
27001
+ tools.push({
27002
+ ...base,
27003
+ description: `${base.description ?? ""}${note}`.trim(),
27004
+ inputSchema: { ...schema, type: "object", required: [.../* @__PURE__ */ new Set([...schema.required ?? [], ...needed])] }
27005
+ });
27006
+ continue;
27007
+ }
27008
+ if (rule.name === "actions_run_trigger") {
27009
+ tools.push({ ...base, description: `${base.description ?? ""} gh-broker: method "delete_workflow_run_logs" is refused; a run's logs are kept.`.trim() });
27010
+ continue;
27011
+ }
26157
27012
  if (rule.name === "issue_write") {
26158
27013
  const pointer = config2.tools.some((t) => t.name === "issue_labels" && t.custom) ? " To add, remove or swap individual labels, use issue_labels instead." : " Read the current labels first and pass the full set you want to keep.";
26159
27014
  tools.push({
@@ -26164,12 +27019,34 @@ async function buildCatalog(config2, secrets, upstream) {
26164
27019
  }
26165
27020
  tools.push(base);
26166
27021
  }
26167
- return tools;
27022
+ return withOtp(tools, config2);
27023
+ }
27024
+ function withOtp(tools, config2) {
27025
+ const gated = new Set(config2.tools.filter(isGated).map((t) => t.name));
27026
+ const window = windowText(otpSettings(config2).windowSeconds);
27027
+ return tools.map((tool) => {
27028
+ if (!gated.has(tool.name)) return tool;
27029
+ const schema = tool.inputSchema;
27030
+ return {
27031
+ ...tool,
27032
+ description: `${tool.description ?? ""} Needs a broker OTP (gh-broker): ask the user for a current code from their authenticator app and pass it as "${OTP_ARG}". One code works for every gated tool for ${window} after the first call that uses it.`.trim(),
27033
+ inputSchema: {
27034
+ ...schema,
27035
+ type: "object",
27036
+ properties: {
27037
+ ...schema.properties ?? {},
27038
+ [OTP_ARG]: { type: "string", description: "gh-broker: a current 6-digit code from the user's authenticator app, or the code that opened the current window" }
27039
+ },
27040
+ required: [...(schema.required ?? []).filter((r) => r !== OTP_ARG), OTP_ARG]
27041
+ }
27042
+ };
27043
+ });
26168
27044
  }
26169
27045
 
26170
27046
  // src/server.ts
26171
27047
  import { createServer } from "node:net";
26172
- import { chmodSync, chownSync, existsSync, readFileSync as readFileSync3, unlinkSync } from "node:fs";
27048
+ import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync4, rmSync, unlinkSync as unlinkSync2 } from "node:fs";
27049
+ import { join as join4 } from "node:path";
26173
27050
 
26174
27051
  // node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js
26175
27052
  var ExperimentalServerTasks = class {
@@ -26864,26 +27741,12 @@ var StdioServerTransport = class {
26864
27741
 
26865
27742
  // src/server.ts
26866
27743
  var SERVER_INFO = { name: "gh-broker", version: "0.1.0" };
26867
- function socketPath(listen) {
26868
- if (!listen.startsWith("unix:")) throw new Error(`server: unsupported listen "${listen}"`);
26869
- return listen.slice("unix:".length);
26870
- }
26871
- function groupGid(name) {
26872
- try {
26873
- for (const line of readFileSync3("/etc/group", "utf8").split("\n")) {
26874
- const parts = line.split(":");
26875
- if (parts[0] === name && parts[2]) return Number(parts[2]);
26876
- }
26877
- } catch {
26878
- }
26879
- return void 0;
26880
- }
26881
- function handleConnection(socket, router, catalog) {
27744
+ function handleConnection(socket, router, catalog, user) {
26882
27745
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
26883
27746
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
26884
27747
  server.setRequestHandler(CallToolRequestSchema, async (req2) => {
26885
27748
  const args = req2.params.arguments ?? {};
26886
- return router.call(req2.params.name, args);
27749
+ return router.call(req2.params.name, args, { user });
26887
27750
  });
26888
27751
  const transport = new StdioServerTransport(socket, socket);
26889
27752
  server.connect(transport).catch((err) => {
@@ -26895,24 +27758,59 @@ function handleConnection(socket, router, catalog) {
26895
27758
  socket.on("error", () => {
26896
27759
  });
26897
27760
  }
26898
- function startServer(config2, router, catalog) {
26899
- const path = socketPath(config2.listen);
26900
- if (existsSync(path)) unlinkSync(path);
27761
+ function readAgents(groupFile) {
27762
+ let text;
27763
+ try {
27764
+ text = readFileSync4(groupFile, "utf8");
27765
+ } catch (err) {
27766
+ console.error(`gh-broker: cannot read ${groupFile} (${err.message}); serving no agents`);
27767
+ return [];
27768
+ }
27769
+ const { agents, skipped } = parseAgentGroups(text);
27770
+ for (const why of skipped) console.error(`gh-broker: ignoring agent group ${why}`);
27771
+ return agents;
27772
+ }
27773
+ function listenFor(path, agent, router, catalog) {
27774
+ if (existsSync(path)) unlinkSync2(path);
26901
27775
  return new Promise((resolve, reject) => {
26902
- const server = createServer((sock) => handleConnection(sock, router, catalog));
27776
+ const server = createServer((sock) => handleConnection(sock, router, catalog, agent.user));
26903
27777
  server.on("error", reject);
26904
27778
  server.listen(path, () => {
26905
- const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
26906
- const gid = groupGid(group);
26907
27779
  try {
26908
- if (gid !== void 0) chownSync(path, process.getuid?.() ?? -1, gid);
27780
+ chownSync(path, process.getuid?.() ?? -1, agent.gid);
27781
+ } catch (err) {
27782
+ console.error(`gh-broker: cannot give ${path} to group ${agent.group} (${err.message}); ${agent.user} can't connect`);
27783
+ }
27784
+ try {
26909
27785
  chmodSync(path, 432);
26910
- } catch {
27786
+ } catch (err) {
27787
+ server.close();
27788
+ reject(err);
27789
+ return;
26911
27790
  }
26912
27791
  resolve();
26913
27792
  });
26914
27793
  });
26915
27794
  }
27795
+ async function startServer(config2, router, catalog, options = {}) {
27796
+ const dir = agentsDir(config2.listen);
27797
+ if (!dir) throw new Error(`server: unsupported listen "${config2.listen}" (expected "agents:<absolute dir>")`);
27798
+ const agents = readAgents(options.groupFile ?? "/etc/group");
27799
+ if (!existsSync(dir)) {
27800
+ mkdirSync(dir, { recursive: true });
27801
+ chmodSync(dir, 493);
27802
+ }
27803
+ const wanted = new Set(agents.map((a) => `${a.user}.sock`));
27804
+ for (const name of readdirSync(dir)) {
27805
+ if (name.endsWith(".sock") && !wanted.has(name)) rmSync(join4(dir, name), { force: true });
27806
+ }
27807
+ if (agents.length === 0) {
27808
+ console.error("gh-broker: no agents registered (no gh-broker-agent-<user> group with members gh-broker and <user>); serving nothing. Add one with: sudo gh-broker agent add <user>");
27809
+ return [];
27810
+ }
27811
+ for (const agent of agents) await listenFor(join4(dir, `${agent.user}.sock`), agent, router, catalog);
27812
+ return agents.map((a) => a.user);
27813
+ }
26916
27814
 
26917
27815
  // src/index.ts
26918
27816
  init_paths();
@@ -26958,12 +27856,13 @@ async function main() {
26958
27856
  }
26959
27857
  const router = new Router(config2, secrets, upstream, appAuth);
26960
27858
  const catalog = await buildCatalog(config2, secrets, upstream);
26961
- await startServer(config2, router, catalog);
27859
+ const agents = await startServer(config2, router, catalog);
26962
27860
  console.log(
26963
27861
  JSON.stringify({
26964
27862
  ts: (/* @__PURE__ */ new Date()).toISOString(),
26965
27863
  event: "listening",
26966
27864
  listen: config2.listen,
27865
+ agents,
26967
27866
  owners: Object.keys(config2.owners),
26968
27867
  app: appAuth ? "enabled" : "none",
26969
27868
  tools: catalog.map((t) => t.name)