@cldmv/gh-broker 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -23295,7 +23514,7 @@ function tarballDigests(tarball) {
23295
23514
  var PLACEHOLDER_VERSION = "0.0.0-stage";
23296
23515
  var SCAFFOLD_MARKER = "gh-broker";
23297
23516
  var DEFAULT_WORKFLOW = "publish.yml";
23298
- var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "otp", "workflow"]);
23517
+ var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "npm_otp", "workflow"]);
23299
23518
  var PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
23300
23519
  var NPM_SCAFFOLD_SCHEMA = {
23301
23520
  type: "object",
@@ -23303,10 +23522,10 @@ var NPM_SCAFFOLD_SCHEMA = {
23303
23522
  owner: { type: "string", description: "Repository owner (a configured owner)." },
23304
23523
  repo: { type: "string", description: "Repository whose package.json names the package." },
23305
23524
  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." },
23525
+ 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
23526
  workflow: { type: "string", description: `The repo's publish workflow file in .github/workflows. Default "publish.yml".` }
23308
23527
  },
23309
- required: ["owner", "repo", "package", "otp"],
23528
+ required: ["owner", "repo", "package", "npm_otp"],
23310
23529
  additionalProperties: false
23311
23530
  };
23312
23531
  var fail3 = (msg) => {
@@ -23320,7 +23539,7 @@ function validate3(input) {
23320
23539
  if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail3('"owner" must be a GitHub owner name');
23321
23540
  if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail3('"repo" must be a repository name');
23322
23541
  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');
23542
+ 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
23543
  if (a.workflow !== void 0 && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
23325
23544
  fail3('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
23326
23545
  }
@@ -23328,7 +23547,7 @@ function validate3(input) {
23328
23547
  owner: a.owner,
23329
23548
  repo: a.repo,
23330
23549
  package: a.package,
23331
- otp: a.otp,
23550
+ npm_otp: a.npm_otp,
23332
23551
  workflow: a.workflow ?? DEFAULT_WORKFLOW
23333
23552
  };
23334
23553
  }
@@ -23439,7 +23658,7 @@ async function npmScaffold(input, deps) {
23439
23658
  const existing = await deps.registry.packument(args.package);
23440
23659
  if (!existing) {
23441
23660
  try {
23442
- await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.otp);
23661
+ await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.npm_otp);
23443
23662
  } catch (err) {
23444
23663
  if (err instanceof NpmOtpRequired) {
23445
23664
  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 +23683,7 @@ async function npmScaffold(input, deps) {
23464
23683
  }
23465
23684
  let out;
23466
23685
  try {
23467
- out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.otp);
23686
+ out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.npm_otp);
23468
23687
  } catch (err) {
23469
23688
  if (err instanceof NpmOtpRequired) {
23470
23689
  return {
@@ -23511,10 +23730,187 @@ async function npmScaffold(input, deps) {
23511
23730
  };
23512
23731
  }
23513
23732
 
23733
+ // src/verbs/minimize_comment.ts
23734
+ var MINIMIZE_CLASSIFIERS = ["RESOLVED", "OUTDATED", "DUPLICATE", "OFF_TOPIC", "SPAM", "ABUSE"];
23735
+ var MINIMIZE = `
23736
+ mutation($id: ID!, $classifier: ReportedContentClassifiers!) {
23737
+ minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
23738
+ minimizedComment { isMinimized minimizedReason }
23739
+ }
23740
+ }`;
23741
+ var UNMINIMIZE = `
23742
+ mutation($id: ID!) {
23743
+ unminimizeComment(input: { subjectId: $id }) {
23744
+ unminimizedComment { isMinimized minimizedReason }
23745
+ }
23746
+ }`;
23747
+ async function nodeIdOf(octokit, input) {
23748
+ if (input.commentId !== void 0) {
23749
+ if (typeof input.commentId !== "string" || !input.commentId.trim()) throw new Error("minimize_comment: commentId must be a comment node id");
23750
+ if (input.commentNumber !== void 0) throw new Error("minimize_comment: pass commentId or commentNumber, not both");
23751
+ return input.commentId;
23752
+ }
23753
+ if (!Number.isSafeInteger(input.commentNumber) || input.commentNumber <= 0) {
23754
+ throw new Error("minimize_comment: pass commentId (node id), or commentNumber (REST id) with type and repo");
23755
+ }
23756
+ 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)');
23757
+ if (typeof input.repo !== "string" || !input.repo) throw new Error("minimize_comment: commentNumber needs repo");
23758
+ const route = input.type === "issue" ? "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" : "GET /repos/{owner}/{repo}/pulls/comments/{comment_id}";
23759
+ const res = await octokit.request(route, { owner: input.owner, repo: input.repo, comment_id: input.commentNumber });
23760
+ const nodeId = res.data.node_id;
23761
+ if (typeof nodeId !== "string" || !nodeId) throw new Error(`minimize_comment: GitHub returned no node id for ${input.type} comment ${input.commentNumber}`);
23762
+ return nodeId;
23763
+ }
23764
+ async function minimizeComment(octokit, input) {
23765
+ const classifier = input.classifier ?? "RESOLVED";
23766
+ if (input.unhide === true && input.classifier !== void 0) throw new Error("minimize_comment: unhide takes no classifier");
23767
+ if (!MINIMIZE_CLASSIFIERS.includes(classifier)) throw new Error(`minimize_comment: classifier must be one of ${MINIMIZE_CLASSIFIERS.join(", ")}`);
23768
+ const id = await nodeIdOf(octokit, input);
23769
+ if (input.unhide === true) {
23770
+ const data2 = await octokit.graphql(UNMINIMIZE, { id });
23771
+ const c2 = data2.unminimizeComment?.unminimizedComment;
23772
+ if (!c2) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
23773
+ return { commentId: id, isMinimized: c2.isMinimized, minimizedReason: c2.minimizedReason ?? null };
23774
+ }
23775
+ const data = await octokit.graphql(MINIMIZE, { id, classifier });
23776
+ const c = data.minimizeComment?.minimizedComment;
23777
+ if (!c) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
23778
+ return { commentId: id, isMinimized: c.isMinimized, minimizedReason: c.minimizedReason ?? null };
23779
+ }
23780
+ 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.`;
23781
+ var MINIMIZE_COMMENT_SCHEMA = {
23782
+ type: "object",
23783
+ required: ["owner"],
23784
+ additionalProperties: false,
23785
+ properties: {
23786
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23787
+ repo: { type: "string", description: "repository name (needed with commentNumber)" },
23788
+ commentId: { type: "string", description: 'comment node id, e.g. "IC_\u2026" or "PRRC_\u2026"' },
23789
+ commentNumber: { type: "number", description: "REST comment id, used with type and repo when the node id isn't at hand" },
23790
+ type: { type: "string", enum: ["issue", "review"], description: "with commentNumber: issue (issue or PR conversation comment) or review (PR review comment)" },
23791
+ classifier: { type: "string", enum: [...MINIMIZE_CLASSIFIERS], description: "why it's hidden (default RESOLVED)" },
23792
+ unhide: { type: "boolean", description: "true shows a hidden comment again" }
23793
+ }
23794
+ };
23795
+
23796
+ // src/verbs/delete_branch.ts
23797
+ var KEPT_BRANCHES = /* @__PURE__ */ new Set(["master", "main", "next", "hotfixes", "dev", "badges", "gh-pages"]);
23798
+ async function deleteBranch(octokit, input) {
23799
+ const { owner: owner2, repo } = input;
23800
+ if (typeof repo !== "string" || !repo) throw new Error("delete_branch: repo is required");
23801
+ const branch = typeof input.branch === "string" ? input.branch.replace(/^refs\/heads\//, "") : "";
23802
+ if (!branch) throw new Error("delete_branch: branch is required");
23803
+ if (KEPT_BRANCHES.has(branch)) throw new Error(`delete_branch: refusing to delete "${branch}": the release flow depends on it`);
23804
+ const meta2 = await octokit.request("GET /repos/{owner}/{repo}", { owner: owner2, repo });
23805
+ const repoData = meta2.data;
23806
+ const defaultBranch = repoData.default_branch;
23807
+ if (branch === defaultBranch) throw new Error(`delete_branch: refusing to delete "${branch}": it is ${owner2}/${repo}'s default branch`);
23808
+ const lookups = [
23809
+ { side: "head", where: { owner: owner2, repo }, filter: { head: `${owner2}:${branch}` } },
23810
+ { side: "base", where: { owner: owner2, repo }, filter: { base: branch } }
23811
+ ];
23812
+ const upstreams = repoData.fork ? [repoData.parent, repoData.source] : [];
23813
+ const seen = /* @__PURE__ */ new Set();
23814
+ for (const up of upstreams) {
23815
+ if (!up?.owner?.login || !up.name || seen.has(`${up.owner.login}/${up.name}`)) continue;
23816
+ seen.add(`${up.owner.login}/${up.name}`);
23817
+ lookups.push({ side: "head", where: { owner: up.owner.login, repo: up.name }, filter: { head: `${owner2}:${branch}` } });
23818
+ }
23819
+ for (const { side, where, filter } of lookups) {
23820
+ const open2 = await octokit.request("GET /repos/{owner}/{repo}/pulls", { ...where, state: "open", ...filter, per_page: 10 });
23821
+ const prs = open2.data;
23822
+ if (prs.length > 0) {
23823
+ const ref2 = where.owner === owner2 && where.repo === repo ? "" : `${where.owner}/${where.repo}`;
23824
+ 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`);
23825
+ }
23826
+ }
23827
+ const missing = new Error(`delete_branch: no branch "${branch}" in ${owner2}/${repo}`);
23828
+ let ref;
23829
+ try {
23830
+ ref = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
23831
+ } catch (err) {
23832
+ throw err.status === 404 ? missing : err;
23833
+ }
23834
+ const sha = ref.data.object?.sha;
23835
+ if (typeof sha !== "string") throw missing;
23836
+ await octokit.request("DELETE /repos/{owner}/{repo}/git/refs/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
23837
+ return { deleted: branch, sha, restore: `git push origin ${sha}:refs/heads/${branch}` };
23838
+ }
23839
+ 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).";
23840
+ var DELETE_BRANCH_SCHEMA = {
23841
+ type: "object",
23842
+ required: ["owner", "repo", "branch"],
23843
+ additionalProperties: false,
23844
+ properties: {
23845
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23846
+ repo: { type: "string", description: "repository name" },
23847
+ branch: { type: "string", description: 'branch name, e.g. "feat/otp-gate" (a leading refs/heads/ is accepted)' }
23848
+ }
23849
+ };
23850
+
23851
+ // src/verbs/dismiss_alert.ts
23852
+ var ALERT_KINDS = ["code_scanning", "dependabot", "secret_scanning"];
23853
+ var DISMISS_REASONS = {
23854
+ code_scanning: ["false positive", "won't fix", "used in tests"],
23855
+ dependabot: ["fix_started", "inaccurate", "no_bandwidth", "not_used", "tolerable_risk"],
23856
+ secret_scanning: ["false_positive", "wont_fix", "revoked", "used_in_tests"]
23857
+ };
23858
+ var ROUTES = {
23859
+ code_scanning: "PATCH /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}",
23860
+ dependabot: "PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number}",
23861
+ secret_scanning: "PATCH /repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}"
23862
+ };
23863
+ function bodyFor(kind, reopen, reason, comment) {
23864
+ if (kind === "secret_scanning") {
23865
+ if (reopen) return { state: "open", ...comment ? { resolution_comment: comment } : {} };
23866
+ return { state: "resolved", resolution: reason, ...comment ? { resolution_comment: comment } : {} };
23867
+ }
23868
+ if (reopen) return { state: "open" };
23869
+ return { state: "dismissed", dismissed_reason: reason, ...comment ? { dismissed_comment: comment } : {} };
23870
+ }
23871
+ async function dismissAlert(octokit, input) {
23872
+ const { owner: owner2, repo, kind } = input;
23873
+ if (typeof repo !== "string" || !repo) throw new Error("dismiss_alert: repo is required");
23874
+ if (!ALERT_KINDS.includes(kind)) throw new Error(`dismiss_alert: kind must be one of ${ALERT_KINDS.join(", ")}`);
23875
+ if (!Number.isSafeInteger(input.alertNumber) || input.alertNumber <= 0) throw new Error("dismiss_alert: alertNumber must be a positive integer");
23876
+ if (input.comment !== void 0 && typeof input.comment !== "string") throw new Error("dismiss_alert: comment must be a string");
23877
+ const reopen = input.reopen === true;
23878
+ if (reopen && input.reason !== void 0) throw new Error("dismiss_alert: a reopen takes no reason");
23879
+ 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`);
23880
+ if (!reopen && !DISMISS_REASONS[kind].includes(input.reason)) {
23881
+ throw new Error(`dismiss_alert: reason is required for a ${kind} alert, one of: ${DISMISS_REASONS[kind].map((r) => `"${r}"`).join(", ")}`);
23882
+ }
23883
+ const res = await octokit.request(ROUTES[kind], { owner: owner2, repo, alert_number: input.alertNumber, ...bodyFor(kind, reopen, input.reason, input.comment) });
23884
+ const d = res.data;
23885
+ const reason = kind === "secret_scanning" ? d.resolution : d.dismissed_reason;
23886
+ return {
23887
+ kind,
23888
+ number: typeof d.number === "number" ? d.number : input.alertNumber,
23889
+ state: typeof d.state === "string" ? d.state : "unknown",
23890
+ reason: typeof reason === "string" ? reason : null,
23891
+ url: typeof d.html_url === "string" ? d.html_url : null
23892
+ };
23893
+ }
23894
+ 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.`;
23895
+ var DISMISS_ALERT_SCHEMA = {
23896
+ type: "object",
23897
+ required: ["owner", "repo", "kind", "alertNumber"],
23898
+ additionalProperties: false,
23899
+ properties: {
23900
+ owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
23901
+ repo: { type: "string", description: "repository name" },
23902
+ kind: { type: "string", enum: [...ALERT_KINDS], description: "which alert type" },
23903
+ alertNumber: { type: "number", description: "the alert number" },
23904
+ reason: { type: "string", description: "why it's dismissed; the accepted values depend on kind (see the description)" },
23905
+ comment: { type: "string", description: "optional note stored with the dismissal" },
23906
+ reopen: { type: "boolean", description: "true reopens the alert instead of dismissing it (no reason)" }
23907
+ }
23908
+ };
23909
+
23514
23910
  // src/verbs/ghcr/archive.ts
23515
23911
  import { closeSync, constants, fstatSync, openSync, readlinkSync, realpathSync } from "node:fs";
23516
23912
  import { createHash as createHash2 } from "node:crypto";
23517
- import { dirname, isAbsolute as isAbsolute2, join as join3, normalize as normalize2 } from "node:path";
23913
+ import { dirname, isAbsolute as isAbsolute4, join as join3, normalize as normalize2 } from "node:path";
23518
23914
 
23519
23915
  // src/verbs/ghcr/tar.ts
23520
23916
  import { readSync } from "node:fs";
@@ -23685,7 +24081,7 @@ var fail5 = (msg) => {
23685
24081
  };
23686
24082
  function openArchive(input, dropDir, maxBytes) {
23687
24083
  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));
24084
+ const candidate = normalize2(isAbsolute4(input) ? input : join3(dropDir, input));
23689
24085
  const outside = `"archive" must be an existing file inside the drop directory ${dropDir}`;
23690
24086
  if (!candidate.startsWith(`${dropDir}/`)) fail5(outside);
23691
24087
  let realDrop;
@@ -24400,6 +24796,329 @@ var GHCR_CATALOG = {
24400
24796
  }
24401
24797
  };
24402
24798
 
24799
+ // src/otp/gate.ts
24800
+ import { createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
24801
+ import { hostname } from "node:os";
24802
+
24803
+ // src/otp/state.ts
24804
+ import { closeSync as closeSync2, fstatSync as fstatSync2, fsyncSync, openSync as openSync2, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
24805
+ import { randomBytes } from "node:crypto";
24806
+ import { dirname as dirname2 } from "node:path";
24807
+ function emptyState() {
24808
+ return { version: 1, lastAcceptedStep: 0, users: {} };
24809
+ }
24810
+ function emptyUser() {
24811
+ return { failures: 0, lockedUntil: null, lockouts: [], hardLock: null };
24812
+ }
24813
+ function userState(state, user) {
24814
+ const u = Object.hasOwn(state.users, user) ? state.users[user] : emptyUser();
24815
+ return { ...u, lockouts: [...u.lockouts] };
24816
+ }
24817
+ var isIso = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
24818
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
24819
+ var isObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24820
+ function parseUser(raw) {
24821
+ if (!isObject3(raw) || !isCount(raw.failures)) return void 0;
24822
+ if (raw.lockedUntil !== null && !isIso(raw.lockedUntil)) return void 0;
24823
+ if (!Array.isArray(raw.lockouts) || !raw.lockouts.every(isIso)) return void 0;
24824
+ let hardLock = null;
24825
+ if (raw.hardLock !== null) {
24826
+ const h = raw.hardLock;
24827
+ if (!isObject3(h) || !isIso(h.since) || !isCount(h.lockouts)) return void 0;
24828
+ hardLock = { since: h.since, lockouts: h.lockouts };
24829
+ }
24830
+ return { failures: raw.failures, lockedUntil: raw.lockedUntil, lockouts: [...raw.lockouts], hardLock };
24831
+ }
24832
+ function parseState(raw) {
24833
+ if (!isObject3(raw) || raw.version !== 1 || !isCount(raw.lastAcceptedStep) || !isObject3(raw.users)) return void 0;
24834
+ const users = {};
24835
+ for (const [name, entry] of Object.entries(raw.users)) {
24836
+ const u = parseUser(entry);
24837
+ if (!u || !isAgentUser(name)) return void 0;
24838
+ users[name] = u;
24839
+ }
24840
+ return { version: 1, lastAcceptedStep: raw.lastAcceptedStep, users };
24841
+ }
24842
+ function signature(path) {
24843
+ try {
24844
+ const st = statSync(path);
24845
+ return `${st.ino}:${st.mtimeMs}:${st.size}`;
24846
+ } catch (err) {
24847
+ if (err.code === "ENOENT") return "missing";
24848
+ return `error:${err.code}`;
24849
+ }
24850
+ }
24851
+ var OtpStateFile = class {
24852
+ path;
24853
+ now;
24854
+ sig;
24855
+ cached;
24856
+ constructor(path, now = Date.now) {
24857
+ this.path = path;
24858
+ this.now = now;
24859
+ }
24860
+ /** The current state, re-read from disk only when the file changed. */
24861
+ read() {
24862
+ const sig = signature(this.path);
24863
+ if (this.cached && sig === this.sig) return this.cached;
24864
+ this.sig = sig;
24865
+ this.cached = this.load(sig);
24866
+ return this.cached;
24867
+ }
24868
+ load(sig) {
24869
+ if (sig === "missing") return { ok: true, state: emptyState() };
24870
+ let state;
24871
+ let why = "unexpected shape";
24872
+ try {
24873
+ state = parseState(JSON.parse(readFileSync3(this.path, "utf8")));
24874
+ } catch (err) {
24875
+ why = err.message;
24876
+ }
24877
+ if (state) return { ok: true, state };
24878
+ const since = new Date(this.now()).toISOString();
24879
+ 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`);
24880
+ return { ok: false, since };
24881
+ }
24882
+ /**
24883
+ * Persist `state` atomically. On success the cache holds the new state; on failure the
24884
+ * cache still holds it (so in-process counters keep counting) and the error is thrown,
24885
+ * so the caller can refuse anything that depends on the write having landed.
24886
+ */
24887
+ write(state) {
24888
+ this.cached = { ok: true, state };
24889
+ const tmp = `${this.path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
24890
+ let written;
24891
+ try {
24892
+ const fd = openSync2(tmp, "wx", 384);
24893
+ try {
24894
+ writeSync(fd, `${JSON.stringify(state, null, " ")}
24895
+ `);
24896
+ fsyncSync(fd);
24897
+ const st = fstatSync2(fd);
24898
+ written = `${st.ino}:${st.mtimeMs}:${st.size}`;
24899
+ } finally {
24900
+ closeSync2(fd);
24901
+ }
24902
+ renameSync(tmp, this.path);
24903
+ } catch (err) {
24904
+ try {
24905
+ unlinkSync(tmp);
24906
+ } catch {
24907
+ }
24908
+ this.sig = signature(this.path);
24909
+ throw new Error(`OTP state could not be saved to ${this.path}: ${err.message}`);
24910
+ }
24911
+ this.sig = written;
24912
+ syncDir(dirname2(this.path));
24913
+ }
24914
+ };
24915
+ function syncDir(dir) {
24916
+ try {
24917
+ const fd = openSync2(dir, "r");
24918
+ try {
24919
+ fsyncSync(fd);
24920
+ } finally {
24921
+ closeSync2(fd);
24922
+ }
24923
+ } catch {
24924
+ }
24925
+ }
24926
+
24927
+ // src/otp/gate.ts
24928
+ var OtpRefusal = class extends Error {
24929
+ audit;
24930
+ constructor(message, audit2) {
24931
+ super(message);
24932
+ this.name = "OtpRefusal";
24933
+ this.audit = audit2;
24934
+ }
24935
+ };
24936
+ var UNLOCK_COMMAND = "sudo gh-broker otp unlock";
24937
+ var DAY_MS = 24 * 60 * 60 * 1e3;
24938
+ var UNLOCK_ALL_COMMAND = "sudo gh-broker otp unlock --all";
24939
+ function unlockCommand(user) {
24940
+ return `${UNLOCK_COMMAND} ${user}`;
24941
+ }
24942
+ var OtpGate = class {
24943
+ config;
24944
+ secrets;
24945
+ settings;
24946
+ now;
24947
+ host;
24948
+ state;
24949
+ /** Per-process key for window ids. */
24950
+ idKey = randomBytes2(32);
24951
+ windows = [];
24952
+ /** Users whose latest bad-code count is only in memory because the state file couldn't be written. */
24953
+ unsaved = /* @__PURE__ */ new Set();
24954
+ constructor(config2, secrets, options = {}) {
24955
+ this.config = config2;
24956
+ this.secrets = secrets;
24957
+ this.settings = otpSettings(config2);
24958
+ this.now = options.now ?? Date.now;
24959
+ this.host = options.host ?? hostname();
24960
+ this.state = new OtpStateFile(this.settings.statePath, this.now);
24961
+ }
24962
+ /** Whether the gate runs at all. Without config.otp no state file is ever touched. */
24963
+ get enabled() {
24964
+ return this.config.otp !== void 0;
24965
+ }
24966
+ /**
24967
+ * The hard-lock refusal for `user`, if that user is hard-locked (or the state file is
24968
+ * unusable, which locks everyone). The router checks this on every call except otp_status.
24969
+ */
24970
+ hardLockRefusal(user) {
24971
+ if (!this.enabled) return void 0;
24972
+ const read = this.state.read();
24973
+ if (!read.ok) {
24974
+ return new OtpRefusal(
24975
+ `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.`,
24976
+ { otp: "hard-locked" }
24977
+ );
24978
+ }
24979
+ const since = userState(read.state, user).hardLock?.since;
24980
+ if (since === void 0) return void 0;
24981
+ return new OtpRefusal(
24982
+ `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.`,
24983
+ { otp: "hard-locked" }
24984
+ );
24985
+ }
24986
+ /**
24987
+ * Decide `user`'s call to the gated tool `tool` carrying `code` (the raw `otp` argument).
24988
+ * Returns the audit fields when the call may run; throws OtpRefusal when it may not.
24989
+ * Only call this after hardLockRefusal() and the availability check passed (OTP is set up).
24990
+ */
24991
+ authorize(tool, code, user) {
24992
+ const now = this.now();
24993
+ const read = this.state.read();
24994
+ if (!read.ok || userState(read.state, user).hardLock) throw this.hardLockRefusal(user);
24995
+ const state = read.state;
24996
+ const mine = userState(state, user);
24997
+ const lockedUntil = mine.lockedUntil ? Date.parse(mine.lockedUntil) : 0;
24998
+ if (lockedUntil > now) {
24999
+ const minutes = Math.max(1, Math.ceil((lockedUntil - now) / 6e4));
25000
+ throw new OtpRefusal(`OTP is locked for ${minutes} more minute(s) after too many bad codes. Nothing was run.`, { otp: "locked" });
25001
+ }
25002
+ const unsavedRefusal = () => new OtpRefusal(`OTP could not be checked: the broker could not save its OTP state. Nothing was run.`, { otp: "refused" });
25003
+ if (this.unsaved.has(user)) {
25004
+ try {
25005
+ this.state.write(this.compact(state, now));
25006
+ this.unsaved.delete(user);
25007
+ } catch (err) {
25008
+ console.error(`gh-broker: ${err.message}`);
25009
+ throw unsavedRefusal();
25010
+ }
25011
+ }
25012
+ 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" });
25013
+ if (code === void 0 || code === null || code === "") throw refusal();
25014
+ if (typeof code === "number" && Number.isInteger(code) && code >= 0 && code <= 999999) code = String(code).padStart(6, "0");
25015
+ if (typeof code !== "string") throw refusal();
25016
+ this.windows = this.windows.filter((w) => w.expiresAt > now);
25017
+ let hit;
25018
+ for (const w of this.windows) if (w.user === user && safeEqual(w.code, code)) hit = w;
25019
+ if (hit) return { otp: "window", window: hit.id };
25020
+ const step = matchStep(readSeed(this.config, this.secrets).key, code, now);
25021
+ if (step !== void 0 && step > state.lastAcceptedStep) {
25022
+ try {
25023
+ this.state.write(this.compact({ ...state, lastAcceptedStep: step, users: { ...state.users, [user]: { ...mine, failures: 0 } } }, now));
25024
+ } catch (err) {
25025
+ console.error(`gh-broker: ${err.message}`);
25026
+ throw unsavedRefusal();
25027
+ }
25028
+ const id = createHmac2("sha256", this.idKey).update(`${user}\0${code}`).digest("hex").slice(0, 16);
25029
+ const expiresAt = now + this.settings.windowSeconds * 1e3;
25030
+ this.windows.push({ user, code, expiresAt, id });
25031
+ return { otp: "opened", window: id, until: new Date(expiresAt).toISOString() };
25032
+ }
25033
+ if (step !== void 0) throw refusal();
25034
+ this.recordFailure(tool, state, mine, user, now);
25035
+ throw refusal();
25036
+ }
25037
+ /** Count one bad code for `user`, starting a short lockout (and maybe the hard lock) at the limit. */
25038
+ recordFailure(tool, state, mine, user, now) {
25039
+ const next = { ...mine, lockouts: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS) };
25040
+ next.failures += 1;
25041
+ if (next.failures >= this.settings.maxFailures) {
25042
+ const at = new Date(now).toISOString();
25043
+ next.failures = 0;
25044
+ next.lockedUntil = new Date(now + this.settings.lockoutSeconds * 1e3).toISOString();
25045
+ next.lockouts.push(at);
25046
+ if (next.lockouts.length >= this.settings.maxLockouts) next.hardLock = { since: at, lockouts: next.lockouts.length };
25047
+ audit(
25048
+ 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` }
25049
+ );
25050
+ }
25051
+ try {
25052
+ this.state.write(this.compact({ ...state, users: { ...state.users, [user]: next } }, now));
25053
+ } catch (err) {
25054
+ this.unsaved.add(user);
25055
+ console.error(`gh-broker: ${err.message}`);
25056
+ }
25057
+ }
25058
+ /**
25059
+ * `state` tidied for a write: every user's lockouts pruned to the last 24 h, expired short
25060
+ * lockouts cleared, and users left with nothing to record dropped.
25061
+ */
25062
+ compact(state, now) {
25063
+ const users = {};
25064
+ for (const [name, u] of Object.entries(state.users)) {
25065
+ const lockouts = u.lockouts.filter((t) => Date.parse(t) > now - DAY_MS);
25066
+ const lockedUntil = u.lockedUntil && Date.parse(u.lockedUntil) > now ? u.lockedUntil : null;
25067
+ if (u.failures === 0 && lockedUntil === null && lockouts.length === 0 && u.hardLock === null) continue;
25068
+ users[name] = { failures: u.failures, lockedUntil, lockouts, hardLock: u.hardLock };
25069
+ }
25070
+ return { ...state, users };
25071
+ }
25072
+ /**
25073
+ * The otp_status report for `user`: their own windows and counters, plus every hard-locked
25074
+ * user. Never a code, the seed, or anything derived from them.
25075
+ */
25076
+ status(user) {
25077
+ const now = this.now();
25078
+ const gatedTools = this.config.tools.filter((t) => isGated(t) && isEnabled(t)).map((t) => t.name);
25079
+ let mine = emptyUser();
25080
+ let hardLock = null;
25081
+ let lockedUsers = [];
25082
+ let corrupt = false;
25083
+ if (this.enabled) {
25084
+ const read = this.state.read();
25085
+ if (read.ok) {
25086
+ mine = userState(read.state, user);
25087
+ hardLock = mine.hardLock;
25088
+ 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) }));
25089
+ } else {
25090
+ hardLock = { since: read.since, lockouts: 0 };
25091
+ corrupt = true;
25092
+ lockedUsers = [{ user, since: read.since, lockouts: 0, unlockCommand: UNLOCK_ALL_COMMAND }];
25093
+ }
25094
+ }
25095
+ const open2 = this.windows.filter((w) => w.user === user && w.expiresAt > now);
25096
+ const next = open2.length ? Math.min(...open2.map((w) => w.expiresAt)) : void 0;
25097
+ const lockedUntil = mine.lockedUntil && Date.parse(mine.lockedUntil) > now ? mine.lockedUntil : null;
25098
+ return {
25099
+ configured: !("problem" in readSeed(this.config, this.secrets)),
25100
+ user,
25101
+ gatedTools,
25102
+ windowSeconds: this.settings.windowSeconds,
25103
+ openWindows: open2.length,
25104
+ nextWindowExpiresAt: next === void 0 ? null : new Date(next).toISOString(),
25105
+ failures: mine.failures,
25106
+ maxFailures: this.settings.maxFailures,
25107
+ lockedUntil,
25108
+ lockoutsLast24h: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS).length,
25109
+ maxLockouts: this.settings.maxLockouts,
25110
+ hardLock,
25111
+ unlockCommand: corrupt ? UNLOCK_ALL_COMMAND : unlockCommand(user),
25112
+ lockedUsers,
25113
+ host: this.host
25114
+ };
25115
+ }
25116
+ };
25117
+ function windowText(seconds) {
25118
+ if (seconds % 60 === 0) return `${seconds / 60} minute${seconds === 60 ? "" : "s"}`;
25119
+ return `${seconds} seconds`;
25120
+ }
25121
+
24403
25122
  // src/router.ts
24404
25123
  var GHCR_TOOLS2 = /* @__PURE__ */ new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
24405
25124
  var Router = class {
@@ -24410,65 +25129,110 @@ var Router = class {
24410
25129
  rules;
24411
25130
  /** GHCR verbs run on config.ghcr's own credential, not the owner's GitHub PAT (created on first use). */
24412
25131
  ghcr;
24413
- constructor(config2, secrets, upstream, appAuth) {
25132
+ /** The OTP gate (#143). Inert without config.otp. */
25133
+ otp;
25134
+ constructor(config2, secrets, upstream, appAuth, otp) {
24414
25135
  this.config = config2;
24415
25136
  this.secrets = secrets;
24416
25137
  this.upstream = upstream;
24417
25138
  this.appAuth = appAuth;
24418
25139
  this.rules = indexTools(config2.tools);
25140
+ this.otp = otp ?? new OtpGate(config2, secrets);
24419
25141
  }
24420
25142
  /** Tools advertised to the agent (used to build tools/list). */
24421
25143
  advertisedTools() {
24422
25144
  return this.config.tools.filter((rule) => !unavailableReason(rule, this.config, this.secrets));
24423
25145
  }
24424
- async call(tool, args) {
25146
+ /** Run `tool` for `caller`, the agent user whose socket the call came in on. */
25147
+ async call(tool, rawArgs, caller) {
25148
+ const user = caller.user;
24425
25149
  const rule = this.rules.get(tool);
24426
25150
  if (!rule) {
24427
- audit({ tool, decision: "deny", detail: "not in allowlist" });
25151
+ audit({ tool, user, decision: "deny", detail: "not in allowlist" });
24428
25152
  throw new Error(`tool "${tool}" is not allowed`);
24429
25153
  }
25154
+ if (tool !== OTP_STATUS) {
25155
+ const locked = this.otp.hardLockRefusal(user);
25156
+ if (locked) {
25157
+ audit({ tool, user, decision: "deny", detail: "otp hard lock", ...locked.audit });
25158
+ throw locked;
25159
+ }
25160
+ }
25161
+ let args = rawArgs;
25162
+ let code;
25163
+ if (Object.hasOwn(rawArgs, OTP_ARG)) {
25164
+ const { [OTP_ARG]: given, ...rest } = rawArgs;
25165
+ code = given;
25166
+ args = rest;
25167
+ }
24430
25168
  const unavailable = unavailableReason(rule, this.config, this.secrets);
24431
25169
  if (unavailable) {
24432
- audit({ tool, decision: "deny", detail: `unavailable: ${unavailable}` });
25170
+ audit({ tool, user, decision: "deny", detail: `unavailable: ${unavailable}` });
24433
25171
  throw new Error(`tool "${tool}" is not available on this broker: ${unavailable}`);
24434
25172
  }
24435
- if (GHCR_TOOLS2.has(tool)) return this.callGhcr(tool, args, rule);
24436
- let owner2 = extractOwner(args);
25173
+ if (tool === OTP_STATUS) {
25174
+ audit({ tool, user, mode: rule.mode, decision: "custom", ok: true });
25175
+ return { content: [{ type: "text", text: JSON.stringify(this.otp.status(user)) }] };
25176
+ }
25177
+ let createdIn;
25178
+ if (REPO_CREATING.has(tool) || tool === "actions_run_trigger") {
25179
+ try {
25180
+ if (tool === "actions_run_trigger") refuseLogDeletion(args);
25181
+ else createdIn = creationOwner(tool, args, this.config.owners);
25182
+ } catch (err) {
25183
+ const requested = typeof args.organization === "string" ? args.organization : extractOwner(args);
25184
+ audit({ tool, user, mode: rule.mode, ...requested ? { owner: requested.toLowerCase() } : {}, decision: "error", detail: err.message });
25185
+ throw err;
25186
+ }
25187
+ }
25188
+ let otp;
25189
+ if (isGated(rule)) {
25190
+ try {
25191
+ otp = this.otp.authorize(tool, code, user);
25192
+ } catch (err) {
25193
+ audit({ tool, user, decision: "deny", detail: "otp", ...err.audit });
25194
+ throw err;
25195
+ }
25196
+ }
25197
+ if (GHCR_TOOLS2.has(tool)) return this.callGhcr(tool, args, rule, user, otp);
25198
+ let owner2 = createdIn ?? extractOwner(args);
24437
25199
  if (!owner2 && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
24438
25200
  owner2 = this.config.defaultOwner.toLowerCase();
24439
25201
  }
24440
25202
  if (!owner2) {
24441
- audit({ tool, decision: "error", detail: "no owner in args" });
25203
+ audit({ tool, user, decision: "error", detail: "no owner in args" });
24442
25204
  throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
24443
25205
  }
24444
25206
  const creds = this.config.owners[owner2];
24445
25207
  if (!creds) {
24446
- audit({ tool, owner: owner2, decision: "error", detail: "no creds for owner" });
25208
+ audit({ tool, user, owner: owner2, decision: "error", detail: "no creds for owner" });
24447
25209
  throw new Error(`no credentials configured for owner "${owner2}" \u2014 add it to config.owners + creds`);
24448
25210
  }
24449
25211
  const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
24450
- const repo = typeof args.repo === "string" ? `${owner2}/${args.repo}` : owner2;
25212
+ const repoName = tool === "create_repository" ? args.name : args.repo;
25213
+ const repo = typeof repoName === "string" ? `${owner2}/${repoName}` : owner2;
25214
+ const forked = tool === "fork_repository" ? { source: `${String(args.owner)}/${String(args.repo)}` } : {};
24451
25215
  const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
24452
25216
  try {
24453
25217
  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 });
25218
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: true, ...otp });
24455
25219
  return out;
24456
25220
  } catch (err) {
24457
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision, ok: false, detail: err.message });
25221
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: false, detail: err.message, ...otp });
24458
25222
  throw err;
24459
25223
  }
24460
25224
  }
24461
25225
  /** GHCR verbs: config.ghcr checks the owner and image itself; no GitHub PAT is resolved. */
24462
- async callGhcr(tool, args, rule) {
25226
+ async callGhcr(tool, args, rule, user, otp) {
24463
25227
  const owner2 = extractOwner(args) ?? "";
24464
25228
  const repo = typeof args.image === "string" ? `${owner2}/${args.image}` : owner2;
24465
25229
  try {
24466
25230
  this.ghcr ??= new GhcrService(this.config.ghcr, this.secrets);
24467
25231
  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 });
25232
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: true, ...otp });
24469
25233
  return { content: [{ type: "text", text: JSON.stringify(out) }] };
24470
25234
  } catch (err) {
24471
- audit({ tool, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message });
25235
+ audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message, ...otp });
24472
25236
  throw err;
24473
25237
  }
24474
25238
  }
@@ -24543,6 +25307,18 @@ var Router = class {
24543
25307
  const out = await npmScaffold(args, { octokit, registry: registry2, scopes: npm.scopes });
24544
25308
  return { content: [{ type: "text", text: JSON.stringify(out) }] };
24545
25309
  }
25310
+ case "minimize_comment": {
25311
+ const out = await minimizeComment(new Octokit({ auth: token }), args);
25312
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25313
+ }
25314
+ case "delete_branch": {
25315
+ const out = await deleteBranch(new Octokit({ auth: token }), args);
25316
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25317
+ }
25318
+ case "dismiss_alert": {
25319
+ const out = await dismissAlert(new Octokit({ auth: token }), args);
25320
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
25321
+ }
24546
25322
  default:
24547
25323
  throw new Error(`custom verb "${tool}" not implemented`);
24548
25324
  }
@@ -24649,6 +25425,21 @@ var Router = class {
24649
25425
  }
24650
25426
  }
24651
25427
  };
25428
+ var REPO_CREATING = /* @__PURE__ */ new Set(["create_repository", "fork_repository"]);
25429
+ function creationOwner(tool, args, owners) {
25430
+ if (typeof args.organization !== "string" || !args.organization) {
25431
+ 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`);
25432
+ }
25433
+ const org = args.organization.toLowerCase();
25434
+ if (!owners[org]) throw new Error(`no credentials configured for owner "${org}" \u2014 add it to config.owners + creds`);
25435
+ if (tool === "create_repository" && typeof args.private !== "boolean") {
25436
+ throw new Error(`tool "create_repository": "private" is required, true or false; a repo's visibility is never left to a default`);
25437
+ }
25438
+ return org;
25439
+ }
25440
+ function refuseLogDeletion(args) {
25441
+ 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`);
25442
+ }
24652
25443
  function extractOwner(args) {
24653
25444
  if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
24654
25445
  if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
@@ -26016,6 +26807,14 @@ var AppAuth = class {
26016
26807
  }
26017
26808
  };
26018
26809
 
26810
+ // src/verbs/otp_status.ts
26811
+ var OTP_STATUS_SCHEMA = {
26812
+ type: "object",
26813
+ additionalProperties: false,
26814
+ properties: {}
26815
+ };
26816
+ 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.`;
26817
+
26019
26818
  // src/catalog.ts
26020
26819
  async function buildCatalog(config2, secrets, upstream) {
26021
26820
  const upstreamByName = /* @__PURE__ */ new Map();
@@ -26064,7 +26863,7 @@ async function buildCatalog(config2, secrets, upstream) {
26064
26863
  } else if (rule.name === "npm_scaffold") {
26065
26864
  tools.push({
26066
26865
  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.",
26866
+ 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
26867
  inputSchema: NPM_SCAFFOLD_SCHEMA
26069
26868
  });
26070
26869
  } else if (rule.name === "report_unlabeled") {
@@ -26109,6 +26908,14 @@ async function buildCatalog(config2, secrets, upstream) {
26109
26908
  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
26909
  inputSchema: REPORT_CI_STATUS_SCHEMA
26111
26910
  });
26911
+ } else if (rule.name === "minimize_comment") {
26912
+ tools.push({ name: "minimize_comment", description: MINIMIZE_COMMENT_DESCRIPTION, inputSchema: MINIMIZE_COMMENT_SCHEMA });
26913
+ } else if (rule.name === "delete_branch") {
26914
+ tools.push({ name: "delete_branch", description: DELETE_BRANCH_DESCRIPTION, inputSchema: DELETE_BRANCH_SCHEMA });
26915
+ } else if (rule.name === "dismiss_alert") {
26916
+ tools.push({ name: "dismiss_alert", description: DISMISS_ALERT_DESCRIPTION, inputSchema: DISMISS_ALERT_SCHEMA });
26917
+ } else if (rule.name === OTP_STATUS) {
26918
+ tools.push({ name: OTP_STATUS, description: OTP_STATUS_DESCRIPTION, inputSchema: OTP_STATUS_SCHEMA });
26112
26919
  } else if (GHCR_CATALOG[rule.name]) {
26113
26920
  tools.push({ name: rule.name, ...GHCR_CATALOG[rule.name] });
26114
26921
  }
@@ -26154,6 +26961,21 @@ async function buildCatalog(config2, secrets, upstream) {
26154
26961
  });
26155
26962
  continue;
26156
26963
  }
26964
+ if (rule.name === "create_repository" || rule.name === "fork_repository") {
26965
+ const schema = base.inputSchema;
26966
+ const needed = rule.name === "create_repository" ? ["organization", "private"] : ["organization"];
26967
+ 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.';
26968
+ tools.push({
26969
+ ...base,
26970
+ description: `${base.description ?? ""}${note}`.trim(),
26971
+ inputSchema: { ...schema, type: "object", required: [.../* @__PURE__ */ new Set([...schema.required ?? [], ...needed])] }
26972
+ });
26973
+ continue;
26974
+ }
26975
+ if (rule.name === "actions_run_trigger") {
26976
+ tools.push({ ...base, description: `${base.description ?? ""} gh-broker: method "delete_workflow_run_logs" is refused; a run's logs are kept.`.trim() });
26977
+ continue;
26978
+ }
26157
26979
  if (rule.name === "issue_write") {
26158
26980
  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
26981
  tools.push({
@@ -26164,12 +26986,34 @@ async function buildCatalog(config2, secrets, upstream) {
26164
26986
  }
26165
26987
  tools.push(base);
26166
26988
  }
26167
- return tools;
26989
+ return withOtp(tools, config2);
26990
+ }
26991
+ function withOtp(tools, config2) {
26992
+ const gated = new Set(config2.tools.filter(isGated).map((t) => t.name));
26993
+ const window = windowText(otpSettings(config2).windowSeconds);
26994
+ return tools.map((tool) => {
26995
+ if (!gated.has(tool.name)) return tool;
26996
+ const schema = tool.inputSchema;
26997
+ return {
26998
+ ...tool,
26999
+ 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(),
27000
+ inputSchema: {
27001
+ ...schema,
27002
+ type: "object",
27003
+ properties: {
27004
+ ...schema.properties ?? {},
27005
+ [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" }
27006
+ },
27007
+ required: [...(schema.required ?? []).filter((r) => r !== OTP_ARG), OTP_ARG]
27008
+ }
27009
+ };
27010
+ });
26168
27011
  }
26169
27012
 
26170
27013
  // src/server.ts
26171
27014
  import { createServer } from "node:net";
26172
- import { chmodSync, chownSync, existsSync, readFileSync as readFileSync3, unlinkSync } from "node:fs";
27015
+ import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync4, rmSync, unlinkSync as unlinkSync2 } from "node:fs";
27016
+ import { join as join4 } from "node:path";
26173
27017
 
26174
27018
  // node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js
26175
27019
  var ExperimentalServerTasks = class {
@@ -26864,26 +27708,12 @@ var StdioServerTransport = class {
26864
27708
 
26865
27709
  // src/server.ts
26866
27710
  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) {
27711
+ function handleConnection(socket, router, catalog, user) {
26882
27712
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
26883
27713
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
26884
27714
  server.setRequestHandler(CallToolRequestSchema, async (req2) => {
26885
27715
  const args = req2.params.arguments ?? {};
26886
- return router.call(req2.params.name, args);
27716
+ return router.call(req2.params.name, args, { user });
26887
27717
  });
26888
27718
  const transport = new StdioServerTransport(socket, socket);
26889
27719
  server.connect(transport).catch((err) => {
@@ -26895,24 +27725,59 @@ function handleConnection(socket, router, catalog) {
26895
27725
  socket.on("error", () => {
26896
27726
  });
26897
27727
  }
26898
- function startServer(config2, router, catalog) {
26899
- const path = socketPath(config2.listen);
26900
- if (existsSync(path)) unlinkSync(path);
27728
+ function readAgents(groupFile) {
27729
+ let text;
27730
+ try {
27731
+ text = readFileSync4(groupFile, "utf8");
27732
+ } catch (err) {
27733
+ console.error(`gh-broker: cannot read ${groupFile} (${err.message}); serving no agents`);
27734
+ return [];
27735
+ }
27736
+ const { agents, skipped } = parseAgentGroups(text);
27737
+ for (const why of skipped) console.error(`gh-broker: ignoring agent group ${why}`);
27738
+ return agents;
27739
+ }
27740
+ function listenFor(path, agent, router, catalog) {
27741
+ if (existsSync(path)) unlinkSync2(path);
26901
27742
  return new Promise((resolve, reject) => {
26902
- const server = createServer((sock) => handleConnection(sock, router, catalog));
27743
+ const server = createServer((sock) => handleConnection(sock, router, catalog, agent.user));
26903
27744
  server.on("error", reject);
26904
27745
  server.listen(path, () => {
26905
- const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
26906
- const gid = groupGid(group);
26907
27746
  try {
26908
- if (gid !== void 0) chownSync(path, process.getuid?.() ?? -1, gid);
27747
+ chownSync(path, process.getuid?.() ?? -1, agent.gid);
27748
+ } catch (err) {
27749
+ console.error(`gh-broker: cannot give ${path} to group ${agent.group} (${err.message}); ${agent.user} can't connect`);
27750
+ }
27751
+ try {
26909
27752
  chmodSync(path, 432);
26910
- } catch {
27753
+ } catch (err) {
27754
+ server.close();
27755
+ reject(err);
27756
+ return;
26911
27757
  }
26912
27758
  resolve();
26913
27759
  });
26914
27760
  });
26915
27761
  }
27762
+ async function startServer(config2, router, catalog, options = {}) {
27763
+ const dir = agentsDir(config2.listen);
27764
+ if (!dir) throw new Error(`server: unsupported listen "${config2.listen}" (expected "agents:<absolute dir>")`);
27765
+ const agents = readAgents(options.groupFile ?? "/etc/group");
27766
+ if (!existsSync(dir)) {
27767
+ mkdirSync(dir, { recursive: true });
27768
+ chmodSync(dir, 493);
27769
+ }
27770
+ const wanted = new Set(agents.map((a) => `${a.user}.sock`));
27771
+ for (const name of readdirSync(dir)) {
27772
+ if (name.endsWith(".sock") && !wanted.has(name)) rmSync(join4(dir, name), { force: true });
27773
+ }
27774
+ if (agents.length === 0) {
27775
+ 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>");
27776
+ return [];
27777
+ }
27778
+ for (const agent of agents) await listenFor(join4(dir, `${agent.user}.sock`), agent, router, catalog);
27779
+ return agents.map((a) => a.user);
27780
+ }
26916
27781
 
26917
27782
  // src/index.ts
26918
27783
  init_paths();
@@ -26958,12 +27823,13 @@ async function main() {
26958
27823
  }
26959
27824
  const router = new Router(config2, secrets, upstream, appAuth);
26960
27825
  const catalog = await buildCatalog(config2, secrets, upstream);
26961
- await startServer(config2, router, catalog);
27826
+ const agents = await startServer(config2, router, catalog);
26962
27827
  console.log(
26963
27828
  JSON.stringify({
26964
27829
  ts: (/* @__PURE__ */ new Date()).toISOString(),
26965
27830
  event: "listening",
26966
27831
  listen: config2.listen,
27832
+ agents,
26967
27833
  owners: Object.keys(config2.owners),
26968
27834
  app: appAuth ? "enabled" : "none",
26969
27835
  tools: catalog.map((t) => t.name)