@cldmv/gh-broker 0.0.0-stage → 0.7.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.
Files changed (107) hide show
  1. package/.configs/fix-headers.json +3 -0
  2. package/.configs/vitest.config.mjs +44 -0
  3. package/.github/dependabot.yml +111 -0
  4. package/.github/workflows/branch-retention.yml +44 -0
  5. package/.github/workflows/bundle-size.yml +52 -0
  6. package/.github/workflows/ci.yml +389 -0
  7. package/.github/workflows/cla.yml +66 -0
  8. package/.github/workflows/codeql.yml +87 -0
  9. package/.github/workflows/dependabot-auto-merge.yml +62 -0
  10. package/.github/workflows/dependabot-recreate.yml +67 -0
  11. package/.github/workflows/dependency-review.yml +46 -0
  12. package/.github/workflows/feature-pr.yml +75 -0
  13. package/.github/workflows/hotfix-redirector.yml +67 -0
  14. package/.github/workflows/hotfixes-release.yml +70 -0
  15. package/.github/workflows/labeler.yml +48 -0
  16. package/.github/workflows/master-commit-audit.yml +56 -0
  17. package/.github/workflows/member-auto-merge.yml +75 -0
  18. package/.github/workflows/next-release.yml +76 -0
  19. package/.github/workflows/next-reset.yml +50 -0
  20. package/.github/workflows/pr-notify.yml +44 -0
  21. package/.github/workflows/pr-title-normalizer.yml +52 -0
  22. package/.github/workflows/provenance.yml +51 -0
  23. package/.github/workflows/publish.yml +146 -0
  24. package/.github/workflows/release-merge.yml +104 -0
  25. package/.github/workflows/release-notify.yml +41 -0
  26. package/.github/workflows/scorecard.yml +64 -0
  27. package/.github/workflows/stale.yml +50 -0
  28. package/.github/workflows/tag-health.yml +70 -0
  29. package/.github/workflows/update-major-version-tags.yml +94 -0
  30. package/.github/workflows/v4-bootstrap.yml +115 -0
  31. package/.github/workflows/welcome.yml +42 -0
  32. package/LICENSE +202 -0
  33. package/README.md +185 -2
  34. package/config.example.json +281 -0
  35. package/deploy/README.md +92 -0
  36. package/deploy/gh-broker.service +57 -0
  37. package/deploy/install.mjs +344 -0
  38. package/deploy/install.sh +29 -0
  39. package/deploy/lib/config-merge.mjs +56 -0
  40. package/deploy/lib/prompt.mjs +98 -0
  41. package/deploy/lib/secret-store.mjs +114 -0
  42. package/deploy/lib/smoke.mjs +137 -0
  43. package/deploy/lib/system.mjs +222 -0
  44. package/dist/index.mjs +22970 -0
  45. package/dist/index.mjs.map +7 -0
  46. package/docs/TOOLS.md +347 -0
  47. package/docs/agent-user-model.md +89 -0
  48. package/docs/changelog/v0/v0.2.0.md +26 -0
  49. package/docs/changelog/v0/v0.2.1.md +23 -0
  50. package/docs/changelog/v0/v0.2.2.md +23 -0
  51. package/docs/changelog/v0/v0.2.3.md +33 -0
  52. package/docs/changelog/v0/v0.2.4.md +25 -0
  53. package/docs/changelog/v0/v0.2.5.md +29 -0
  54. package/docs/changelog/v0/v0.3.0.md +84 -0
  55. package/docs/changelog/v0/v0.3.1.md +30 -0
  56. package/docs/changelog/v0/v0.3.2.md +24 -0
  57. package/docs/changelog/v0/v0.3.3.md +28 -0
  58. package/docs/changelog/v0/v0.4.0.md +58 -0
  59. package/docs/changelog/v0/v0.4.1.md +33 -0
  60. package/docs/changelog/v0/v0.4.2.md +45 -0
  61. package/docs/changelog/v0/v0.5.0.md +39 -0
  62. package/docs/changelog/v0/v0.5.1.md +28 -0
  63. package/docs/changelog/v0/v0.6.0.md +52 -0
  64. package/docs/changelog/v0/v0.6.1.md +48 -0
  65. package/docs/changelog/v0/v0.7.0.md +71 -0
  66. package/docs/deploy-architecture.md +82 -0
  67. package/package.json +71 -5
  68. package/src/appauth.ts +56 -0
  69. package/src/audit.ts +34 -0
  70. package/src/catalog.ts +165 -0
  71. package/src/config.ts +130 -0
  72. package/src/index.ts +107 -0
  73. package/src/paths.ts +60 -0
  74. package/src/router.ts +258 -0
  75. package/src/secrets.ts +137 -0
  76. package/src/server.ts +91 -0
  77. package/src/types.ts +104 -0
  78. package/src/upstream.ts +85 -0
  79. package/src/verbs/issue_labels.ts +329 -0
  80. package/src/verbs/link_issue.ts +84 -0
  81. package/src/verbs/release-analysis.ts +210 -0
  82. package/src/verbs/repo-tier.ts +90 -0
  83. package/src/verbs/report_ci_status.ts +358 -0
  84. package/src/verbs/report_release_prs.ts +238 -0
  85. package/src/verbs/report_security_alerts.ts +442 -0
  86. package/src/verbs/report_unlabeled.ts +194 -0
  87. package/src/verbs/rest-client.ts +181 -0
  88. package/src/verbs/review_thread.ts +138 -0
  89. package/tests/appauth.test.vitest.mts +76 -0
  90. package/tests/audit.test.vitest.mts +48 -0
  91. package/tests/catalog.test.vitest.mts +180 -0
  92. package/tests/config-merge.test.vitest.mts +58 -0
  93. package/tests/config.test.vitest.mts +314 -0
  94. package/tests/issue-labels.test.vitest.mts +347 -0
  95. package/tests/paths.test.vitest.mts +52 -0
  96. package/tests/release-prs.test.vitest.mts +237 -0
  97. package/tests/rest-client.test.vitest.mts +115 -0
  98. package/tests/router.test.vitest.mts +399 -0
  99. package/tests/run-vitest.mjs +51 -0
  100. package/tests/secret-store.test.vitest.mts +106 -0
  101. package/tests/secrets.test.vitest.mts +155 -0
  102. package/tests/security-alerts.test.vitest.mts +328 -0
  103. package/tests/server.test.vitest.mts +20 -0
  104. package/tests/upstream.test.vitest.mts +91 -0
  105. package/tests/verb-helpers.test.vitest.mts +331 -0
  106. package/tests/verbs.test.vitest.mts +624 -0
  107. package/tsconfig.json +21 -0
package/src/paths.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/paths.ts
5
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { platform, env } from "node:process";
17
+ import { join } from "node:path";
18
+
19
+ /**
20
+ * Platform-neutral path resolution for the config and secrets files.
21
+ *
22
+ * Nothing is hardcoded to /etc. Both files resolve through the same order:
23
+ * 1. an explicit path (a `--config` flag, or GH_BROKER_CONFIG / GH_BROKER_SECRETS),
24
+ * 2. the platform's system config dir (below),
25
+ * 3. the packaged default under that dir.
26
+ *
27
+ * Only the Linux defaults are exercised today; the macOS/Windows entries are
28
+ * wired so a later port is a table entry, not surgery. See docs/deploy-architecture.md.
29
+ */
30
+
31
+ /** The system-wide config directory for the current platform (env-overridable). */
32
+ export function configDir(): string {
33
+ const override = env.GH_BROKER_CONFIG_DIR;
34
+ if (override) return override;
35
+ switch (platform) {
36
+ case "darwin":
37
+ return "/Library/Application Support/gh-broker";
38
+ case "win32":
39
+ return join(env.ProgramData ?? "C:\\ProgramData", "gh-broker");
40
+ default: // linux and other unixes
41
+ return "/etc/gh-broker";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolve the config file path: an explicit argument (from `--config`) wins,
47
+ * then GH_BROKER_CONFIG, then `<configDir>/config.json`.
48
+ */
49
+ export function resolveConfigPath(explicit?: string): string {
50
+ if (explicit) return explicit;
51
+ if (env.GH_BROKER_CONFIG) return env.GH_BROKER_CONFIG;
52
+ return join(configDir(), "config.json");
53
+ }
54
+
55
+ /** Resolve the secrets file path: an explicit argument wins, then GH_BROKER_SECRETS, then `<configDir>/secrets.json`. */
56
+ export function resolveSecretsPath(explicit?: string): string {
57
+ if (explicit) return explicit;
58
+ if (env.GH_BROKER_SECRETS) return env.GH_BROKER_SECRETS;
59
+ return join(configDir(), "secrets.json");
60
+ }
package/src/router.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/router.ts
5
+ * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-16T17:17:03-07:00 (1792196223)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
17
+ import type { Config, SecretStore, ToolRule } from "./types.ts";
18
+ import type { AppAuth } from "./appauth.ts";
19
+ import { Octokit } from "@octokit/core";
20
+ import { indexTools } from "./config.ts";
21
+ import { audit } from "./audit.ts";
22
+ // DEFERRED: link_issue is App-token-only and not wired in — see docs.
23
+ // import { linkIssue, type LinkIssueInput } from "./verbs/link_issue.ts";
24
+ import {
25
+ resolveReviewThread,
26
+ replyReviewThread,
27
+ type ResolveReviewThreadInput,
28
+ type ReplyReviewThreadInput
29
+ } from "./verbs/review_thread.ts";
30
+ import { reportUnlabeled, type ReportUnlabeledInput } from "./verbs/report_unlabeled.ts";
31
+ import { reportSecurityAlerts, type ReportSecurityAlertsInput } from "./verbs/report_security_alerts.ts";
32
+ import { reportReleasePrs, type ReportReleasePrsInput } from "./verbs/report_release_prs.ts";
33
+ import { issueLabels, type IssueLabelsInput } from "./verbs/issue_labels.ts";
34
+ import { reportCiStatus, ciStatusForHeadSha, type ReportCiStatusInput, type FailedJobLogsFetcher } from "./verbs/report_ci_status.ts";
35
+
36
+ /** What the router needs from the upstream (hosted-MCP) client pool. */
37
+ export interface Upstream {
38
+ forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult>;
39
+ }
40
+
41
+ /**
42
+ * Enforces the default-deny allowlist, resolves the (owner, mode) → PAT, and either
43
+ * forwards to the hosted MCP or dispatches a custom verb. The token never leaves here.
44
+ */
45
+ export class Router {
46
+ private readonly config: Config;
47
+ private readonly secrets: SecretStore;
48
+ private readonly upstream: Upstream;
49
+ private readonly appAuth?: AppAuth;
50
+ private readonly rules: Map<string, ToolRule>;
51
+
52
+ constructor(config: Config, secrets: SecretStore, upstream: Upstream, appAuth?: AppAuth) {
53
+ this.config = config;
54
+ this.secrets = secrets;
55
+ this.upstream = upstream;
56
+ this.appAuth = appAuth;
57
+ this.rules = indexTools(config.tools);
58
+ }
59
+
60
+ /** Tools advertised to the agent (used to build tools/list). */
61
+ advertisedTools(): ToolRule[] {
62
+ return this.config.tools;
63
+ }
64
+
65
+ async call(tool: string, args: Record<string, unknown>): Promise<CallToolResult> {
66
+ const rule = this.rules.get(tool);
67
+ if (!rule) {
68
+ audit({ tool, decision: "deny", detail: "not in allowlist" });
69
+ throw new Error(`tool "${tool}" is not allowed`);
70
+ }
71
+ // Owner-less reads (global advisory DB, get_me, notifications) fall back to
72
+ // the configured defaultOwner's READ PAT. Writes never fall back — a write
73
+ // must always name the owner it targets.
74
+ let owner = extractOwner(args);
75
+ if (!owner && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
76
+ owner = this.config.defaultOwner.toLowerCase();
77
+ }
78
+ if (!owner) {
79
+ audit({ tool, decision: "error", detail: "no owner in args" });
80
+ throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
81
+ }
82
+ const creds = this.config.owners[owner];
83
+ if (!creds) {
84
+ audit({ tool, owner, decision: "error", detail: "no creds for owner" });
85
+ throw new Error(`no credentials configured for owner "${owner}" — add it to config.owners + creds`);
86
+ }
87
+ const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
88
+ const repo = typeof args.repo === "string" ? `${owner}/${args.repo}` : owner;
89
+
90
+ const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
91
+ try {
92
+ const out = rule.custom
93
+ ? await this.dispatchCustom(tool, args, owner, token)
94
+ : rule.augment
95
+ ? await this.dispatchAugmented(tool, args, owner, token)
96
+ : await this.upstream.forward(tool, args, token);
97
+ audit({ tool, owner, mode: rule.mode, repo, decision, ok: true });
98
+ return out;
99
+ } catch (err) {
100
+ audit({ tool, owner, mode: rule.mode, repo, decision, ok: false, detail: (err as Error).message });
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ private async dispatchCustom(
106
+ tool: string,
107
+ args: Record<string, unknown>,
108
+ owner: string,
109
+ token: string
110
+ ): Promise<CallToolResult> {
111
+ switch (tool) {
112
+ // DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
113
+ // Re-enable by restoring this case + the import above + the catalog block, adding a
114
+ // link_issue allowlist entry to config, and configuring config.app + owner installationId.
115
+ // case "link_issue": {
116
+ // const octokit = this.appAuth?.octokitFor(owner);
117
+ // if (!octokit) {
118
+ // throw new Error(`link_issue needs a GitHub App for "${owner}": set config.app + owners.${owner}.installationId + the app key cred (addClosingIssueReference is App-token-only — not available to fine-grained PATs)`);
119
+ // }
120
+ // const out = await linkIssue(octokit, args as unknown as LinkIssueInput);
121
+ // return { content: [{ type: "text", text: `linked: PR ${out.pullRequestId} → issue ${out.issueId}` }] };
122
+ // }
123
+ case "resolve_review_thread": {
124
+ // PAT-usable (Pull requests: write) — run on the resolved write PAT, not the App token.
125
+ const octokit = new Octokit({ auth: token });
126
+ const out = await resolveReviewThread(octokit, args as unknown as ResolveReviewThreadInput);
127
+ return { content: [{ type: "text", text: `thread ${out.threadId}: isResolved=${out.isResolved}` }] };
128
+ }
129
+ case "reply_review_thread": {
130
+ const octokit = new Octokit({ auth: token });
131
+ const out = await replyReviewThread(octokit, args as unknown as ReplyReviewThreadInput);
132
+ return { content: [{ type: "text", text: `replied: ${out.url}` }] };
133
+ }
134
+ case "issue_labels": {
135
+ // Per-label REST endpoints (Issues: write) on the resolved write PAT.
136
+ const octokit = new Octokit({ auth: token });
137
+ const out = await issueLabels(octokit, args as unknown as IssueLabelsInput);
138
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
139
+ }
140
+ case "report_unlabeled": {
141
+ const octokit = new Octokit({ auth: token });
142
+ const out = await reportUnlabeled(octokit, args as unknown as ReportUnlabeledInput);
143
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
144
+ }
145
+ case "report_security_alerts": {
146
+ const octokit = new Octokit({ auth: token });
147
+ const out = await reportSecurityAlerts(octokit, args as unknown as ReportSecurityAlertsInput);
148
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
149
+ }
150
+ case "report_release_prs": {
151
+ const octokit = new Octokit({ auth: token });
152
+ const out = await reportReleasePrs(octokit, args as unknown as ReportReleasePrsInput);
153
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
154
+ }
155
+ case "report_ci_status": {
156
+ const octokit = new Octokit({ auth: token });
157
+ const reportArgs = args as unknown as ReportCiStatusInput;
158
+ const out = await reportCiStatus(octokit, reportArgs, this.makeJobLogsFetcher(owner, reportArgs.repo, token));
159
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
160
+ }
161
+ default:
162
+ throw new Error(`custom verb "${tool}" not implemented`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * A failing-run's job logs, via the hosted MCP's own `get_job_logs` tool
168
+ * (`failed_only: true` fetches every failing job in the run in one call) rather
169
+ * than a hand-rolled REST call — see report_ci_status.ts's FailedJobLogsFetcher
170
+ * doc comment for why. Best-effort: any failure here (bad shape, upstream error)
171
+ * just yields an empty map — report_ci_status treats a lookup miss as "no
172
+ * logTail for this job", never a hard failure of the whole report.
173
+ */
174
+ private makeJobLogsFetcher(owner: string, repo: string, token: string): FailedJobLogsFetcher {
175
+ return async (runId, tailLines) => {
176
+ const map = new Map<number, string>();
177
+ try {
178
+ const result = await this.upstream.forward("get_job_logs", { owner, repo, run_id: runId, failed_only: true, return_content: true, tail_lines: tailLines }, token);
179
+ const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
180
+ if (!textBlock?.text) return map;
181
+ const parsed = JSON.parse(textBlock.text) as { logs?: Array<{ job_id?: unknown; logs_content?: unknown }> };
182
+ for (const entry of parsed.logs ?? []) {
183
+ if (typeof entry.job_id === "number" && typeof entry.logs_content === "string") map.set(entry.job_id, entry.logs_content);
184
+ }
185
+ } catch {
186
+ /* best-effort — see doc comment */
187
+ }
188
+ return map;
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Forwards to the hosted MCP like a plain proxy, then post-processes the result.
194
+ * gh-broker presents its own MCP contract — a proxied tool's response is not
195
+ * required to match the hosted MCP's shape verbatim (see [[broker-owns-its-mcp-contract]]
196
+ * in project memory). `ciVerbosity`/`logLines` are gh-broker's own extension
197
+ * params, stripped before forwarding since the upstream schema doesn't know them.
198
+ *
199
+ * Opt-in only: with no `ciVerbosity`, this is byte-identical to a plain forward —
200
+ * augmenting every `pull_request_read` call by default would add an unrequested
201
+ * API call (and cost) to every ordinary PR read.
202
+ *
203
+ * Best-effort: if the upstream response isn't the JSON-text shape expected (a
204
+ * format this broker doesn't control), the original result is returned unmodified
205
+ * rather than thrown — a shape mismatch should degrade to "no CI data added," not
206
+ * break an otherwise-successful PR read.
207
+ */
208
+ private async dispatchAugmented(tool: string, args: Record<string, unknown>, owner: string, token: string): Promise<CallToolResult> {
209
+ switch (tool) {
210
+ case "pull_request_read": {
211
+ const { ciVerbosity, logLines, ...upstreamArgs } = args as Record<string, unknown> & { ciVerbosity?: number; logLines?: number };
212
+ const result = await this.upstream.forward(tool, upstreamArgs, token);
213
+ if (ciVerbosity === undefined) return result;
214
+ if ((upstreamArgs.method ?? "get") !== "get") return result; // only the single-PR "get" shape is augmentable
215
+
216
+ const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
217
+ if (!textBlock?.text) return result;
218
+ let pr: unknown;
219
+ try {
220
+ pr = JSON.parse(textBlock.text);
221
+ } catch {
222
+ return result;
223
+ }
224
+ const p = pr as { number?: unknown; head?: { sha?: unknown } };
225
+ if (typeof p.number !== "number" || typeof p.head?.sha !== "string") return result;
226
+
227
+ const repo = String(upstreamArgs.repo ?? "");
228
+ const octokit = new Octokit({ auth: token });
229
+ const ci = await ciStatusForHeadSha(octokit, owner, repo, p.number, p.head.sha, {
230
+ verbosity: Number(ciVerbosity),
231
+ logLines: logLines !== undefined ? Number(logLines) : undefined,
232
+ fetchFailedJobLogs: this.makeJobLogsFetcher(owner, repo, token)
233
+ });
234
+ textBlock.text = JSON.stringify({ ...(pr as object), ci });
235
+ return result;
236
+ }
237
+ default:
238
+ return this.upstream.forward(tool, args, token);
239
+ }
240
+ }
241
+ }
242
+
243
+ /** Derive the repo owner from a tool call's arguments, so we pick the right PAT. */
244
+ export function extractOwner(args: Record<string, unknown>): string | undefined {
245
+ if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
246
+ // Many org-scoped tools (list_org_repository_security_advisories, get_team_members, …)
247
+ // name the org as `org` rather than `owner`; for routing, org === owner.
248
+ if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
249
+ if (typeof args.query === "string") {
250
+ const m = args.query.match(/\brepo:([^/\s]+)\/[^\s]+|\b(?:org|user):([^\s]+)/i);
251
+ const hit = m?.[1] ?? m?.[2];
252
+ if (hit) return hit.toLowerCase();
253
+ }
254
+ if (typeof args.repository === "string" && args.repository.includes("/")) {
255
+ return args.repository.split("/")[0]?.toLowerCase();
256
+ }
257
+ return undefined;
258
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/secrets.ts
5
+ * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import type { Config, SecretStore } from "./types.ts";
19
+ import { resolveSecretsPath } from "./paths.ts";
20
+
21
+ const CRED_PREFIX = "cred:";
22
+
23
+ /**
24
+ * Loads PATs from the configured secret store and returns a resolver mapping
25
+ * "cred:<name>" refs to token values. Never writes or logs a token.
26
+ *
27
+ * Provider seam (chosen at install time, recorded in config.secrets.provider):
28
+ * - "systemd-creds" (default) — tokens are systemd-encrypted, host/TPM-bound
29
+ * `.cred` files delivered via LoadCredentialEncrypted into $CREDENTIALS_DIRECTORY
30
+ * (memory-only, service-user-readable). Encrypted at rest; not portable.
31
+ * - "file" — a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path.
32
+ * Plaintext at rest (perms only); portable and platform-neutral.
33
+ *
34
+ * Both keep tokens off any agent-readable path — the agent boundary is filesystem
35
+ * perms either way. See docs/deploy-architecture.md.
36
+ */
37
+ export function loadSecretStore(config: Config): SecretStore {
38
+ const provider = config.secrets?.provider ?? "systemd-creds";
39
+ switch (provider) {
40
+ case "systemd-creds":
41
+ return systemdCredsStore(config);
42
+ case "file":
43
+ return fileStore(config);
44
+ default:
45
+ throw new Error(`secrets: unknown provider "${provider}" (expected "systemd-creds" or "file")`);
46
+ }
47
+ }
48
+
49
+ const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
50
+
51
+ /**
52
+ * Credential names become filesystem path components (`join(dir, name)`) and are
53
+ * interpolated into systemd drop-in lines, so constrain them to a safe charset —
54
+ * no path separators, "..", colons, or control characters — to prevent reads
55
+ * outside $CREDENTIALS_DIRECTORY and malformed drop-ins.
56
+ */
57
+ function assertSafeName(name: string): string {
58
+ if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
59
+ throw new Error(`secrets: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
60
+ }
61
+ return name;
62
+ }
63
+
64
+ function credName(ref: string): string {
65
+ if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: bad credential ref "${ref}"`);
66
+ return assertSafeName(ref.slice(CRED_PREFIX.length));
67
+ }
68
+
69
+ /** Eagerly read every referenced credential so a missing token fails at startup, not on first request. */
70
+ function validateRefs(config: Config, read: (name: string) => string): void {
71
+ for (const [owner, creds] of Object.entries(config.owners)) {
72
+ for (const ref of [creds.read, creds.write]) {
73
+ if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: owner "${owner}" ref must be "cred:<name>", got "${ref}"`);
74
+ read(assertSafeName(ref.slice(CRED_PREFIX.length)));
75
+ }
76
+ }
77
+ if (config.app) {
78
+ // The App key is a secret too — it must be a cred: ref, never inlined in the
79
+ // token-free config. Reject a non-cred ref rather than silently accepting it.
80
+ if (!config.app.privateKey.startsWith(CRED_PREFIX)) {
81
+ throw new Error(`secrets: config.app.privateKey must be a "cred:<name>" ref, got "${config.app.privateKey}"`);
82
+ }
83
+ read(assertSafeName(config.app.privateKey.slice(CRED_PREFIX.length)));
84
+ }
85
+ }
86
+
87
+ /** systemd-creds provider: read decrypted creds from $CREDENTIALS_DIRECTORY. */
88
+ function systemdCredsStore(config: Config): SecretStore {
89
+ const dir = process.env.CREDENTIALS_DIRECTORY;
90
+ if (!dir) {
91
+ throw new Error("secrets: $CREDENTIALS_DIRECTORY unset — the systemd-creds provider needs LoadCredentialEncrypted= (run under systemd)");
92
+ }
93
+ const cache = new Map<string, string>();
94
+ const read = (name: string): string => {
95
+ const cached = cache.get(name);
96
+ if (cached !== undefined) return cached;
97
+ let val: string;
98
+ try {
99
+ val = readFileSync(join(dir, name), "utf8").replace(/\r?\n$/, "");
100
+ } catch (err) {
101
+ throw new Error(`secrets: credential "${name}" not available: ${(err as Error).message}`);
102
+ }
103
+ if (!val) throw new Error(`secrets: credential "${name}" is empty`);
104
+ cache.set(name, val);
105
+ return val;
106
+ };
107
+ validateRefs(config, read);
108
+ return { resolve: (ref) => read(credName(ref)) };
109
+ }
110
+
111
+ /** file provider: read tokens from a JSON `{ "<name>": "<token>" }` map. */
112
+ function fileStore(config: Config): SecretStore {
113
+ const path = resolveSecretsPath(config.secrets?.path);
114
+ let raw: unknown;
115
+ try {
116
+ raw = JSON.parse(readFileSync(path, "utf8"));
117
+ } catch (err) {
118
+ throw new Error(`secrets: cannot read/parse secrets file ${path}: ${(err as Error).message}`);
119
+ }
120
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
121
+ throw new Error(`secrets: ${path} must be a JSON object of { "<name>": "<token>" }`);
122
+ }
123
+ const map = raw as Record<string, unknown>; // read once into memory; never re-read or write
124
+
125
+ const read = (name: string): string => {
126
+ const val = map[name];
127
+ if (typeof val !== "string") throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
128
+ // Normalize like the systemd-creds provider: strip a single trailing newline
129
+ // (common when a token is copy/pasted into the JSON) so it isn't silently
130
+ // mismatched at auth time. A value that is only a newline then reads as empty.
131
+ const token = val.replace(/\r?\n$/, "");
132
+ if (token.length === 0) throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
133
+ return token;
134
+ };
135
+ validateRefs(config, read);
136
+ return { resolve: (ref) => read(credName(ref)) };
137
+ }
package/src/server.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/server.ts
5
+ * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { createServer, type Socket } from "node:net";
17
+ import { chmodSync, chownSync, existsSync, readFileSync, unlinkSync } from "node:fs";
18
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js";
21
+ import type { Config } from "./types.ts";
22
+ import type { Router } from "./router.ts";
23
+
24
+ const SERVER_INFO = { name: "gh-broker", version: "0.1.0" };
25
+
26
+ function socketPath(listen: string): string {
27
+ if (!listen.startsWith("unix:")) throw new Error(`server: unsupported listen "${listen}"`);
28
+ return listen.slice("unix:".length);
29
+ }
30
+
31
+ /** Resolve a group name → gid via /etc/group (no native Node API for this). */
32
+ function groupGid(name: string): number | undefined {
33
+ try {
34
+ for (const line of readFileSync("/etc/group", "utf8").split("\n")) {
35
+ const parts = line.split(":");
36
+ if (parts[0] === name && parts[2]) return Number(parts[2]);
37
+ }
38
+ } catch {
39
+ /* ignore — fall back to RuntimeDirectory perms */
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Each agent connection (a socat stdio bridge to our Unix socket) gets its own MCP
46
+ * Server over a StdioServerTransport bound to that socket. `tools/list` returns the
47
+ * pre-built catalog; `tools/call` delegates to the router (allowlist + owner/mode →
48
+ * PAT + forward/dispatch). The token never crosses to this side.
49
+ */
50
+ function handleConnection(socket: Socket, router: Router, catalog: Tool[]): void {
51
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
52
+
53
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
54
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
55
+ const args = (req.params.arguments ?? {}) as Record<string, unknown>;
56
+ return router.call(req.params.name, args);
57
+ });
58
+
59
+ const transport = new StdioServerTransport(socket, socket);
60
+ server.connect(transport).catch((err: unknown) => {
61
+ console.error(`gh-broker: connection error: ${(err as Error).message}`);
62
+ socket.destroy();
63
+ });
64
+ socket.on("close", () => void server.close().catch(() => {}));
65
+ socket.on("error", () => {
66
+ /* client hangup — ignore */
67
+ });
68
+ }
69
+
70
+ export function startServer(config: Config, router: Router, catalog: Tool[]): Promise<void> {
71
+ const path = socketPath(config.listen);
72
+ if (existsSync(path)) unlinkSync(path); // clear a stale socket from a prior run
73
+
74
+ return new Promise((resolve, reject) => {
75
+ const server = createServer((sock: Socket) => handleConnection(sock, router, catalog));
76
+ server.on("error", reject);
77
+ server.listen(path, () => {
78
+ // Restrict the socket to the agent-clients group: chgrp to gh-broker-clients,
79
+ // mode 0660 (owner+group rw). The agent user is a member of that group.
80
+ const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
81
+ const gid = groupGid(group);
82
+ try {
83
+ if (gid !== undefined) chownSync(path, process.getuid?.() ?? -1, gid);
84
+ chmodSync(path, 0o660);
85
+ } catch {
86
+ /* best-effort; RuntimeDirectory perms already restrict access */
87
+ }
88
+ resolve();
89
+ });
90
+ });
91
+ }
package/src/types.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/types.ts
5
+ * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Config + internal types for gh-broker.
18
+ *
19
+ * The on-disk config holds no secrets — only the tool allowlist and, per owner,
20
+ * the *names* of the credentials to use. Actual PAT values are resolved at
21
+ * runtime from a separate, agent-unreadable secrets store (see secrets.ts and
22
+ * docs/deploy-architecture.md). Keeping the config token-free is what makes it
23
+ * safe to copy between machines.
24
+ */
25
+
26
+ export type Mode = "read" | "write";
27
+
28
+ /**
29
+ * One entry in the default-deny allowlist. `custom` verbs are handled locally, not
30
+ * proxied. `augment` tools ARE proxied (forwarded to the hosted MCP) but the router
31
+ * post-processes the response afterward — gh-broker's own MCP contract for a proxied
32
+ * tool is not required to match the hosted MCP's exact schema/response verbatim (see
33
+ * router.ts's dispatchAugmented). `custom` and `augment` are mutually exclusive.
34
+ */
35
+ export interface ToolRule {
36
+ name: string;
37
+ mode: Mode;
38
+ custom?: boolean;
39
+ augment?: boolean;
40
+ }
41
+
42
+ /** Credential *references* (e.g. "cred:cldmv-read"), resolved via the SecretStore. */
43
+ export interface OwnerCreds {
44
+ read: string;
45
+ write: string;
46
+ /**
47
+ * GitHub App installation id for this owner. Required only for verbs that need
48
+ * an App token (currently `link_issue` → addClosingIssueReference, which is not
49
+ * available to fine-grained PATs). Omit if the owner has no App installed.
50
+ */
51
+ installationId?: number;
52
+ }
53
+
54
+ /** GitHub App config — for App-token-only operations (`link_issue`). Optional. */
55
+ export interface AppConfig {
56
+ appId: number;
57
+ /** Credential reference (e.g. "cred:gh-app-key") for the App's PEM private key. */
58
+ privateKey: string;
59
+ }
60
+
61
+ /**
62
+ * Where the actual token values live — chosen at install time. A provider seam:
63
+ * - "systemd-creds" (default): encrypted, host-bound `.cred` files via
64
+ * LoadCredentialEncrypted → $CREDENTIALS_DIRECTORY (encrypted at rest, not portable).
65
+ * - "file": a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path
66
+ * (plaintext at rest, portable, platform-neutral).
67
+ * Further providers (OS keychain, DPAPI, encrypted vault) can be added here
68
+ * without touching call sites. Omit to use the systemd-creds provider.
69
+ */
70
+ export interface SecretsConfig {
71
+ provider?: "systemd-creds" | "file";
72
+ /** For provider "file": path to the JSON secrets file. Defaults to <configDir>/secrets.json. */
73
+ path?: string;
74
+ }
75
+
76
+ export interface Config {
77
+ /** e.g. "unix:/run/gh-broker/gh-broker.sock" */
78
+ listen: string;
79
+ /** GitHub hosted MCP base URL, e.g. "https://api.githubcopilot.com/mcp/" */
80
+ upstream: string;
81
+ tools: ToolRule[];
82
+ owners: Record<string, OwnerCreds>;
83
+ /**
84
+ * Owner whose READ PAT handles calls that carry no owner in their args —
85
+ * the genuinely global/identity reads (global security-advisory DB, `get_me`,
86
+ * `list_notifications`). Must be a key of `owners`. Applies to `mode: "read"`
87
+ * only; write calls always require an explicit owner. Omit to reject every
88
+ * owner-less call (the stricter original behaviour).
89
+ */
90
+ defaultOwner?: string;
91
+ /**
92
+ * Optional GitHub App, reserved for `link_issue` (an App-token-only mutation).
93
+ * `link_issue` is currently deferred — it is neither advertised nor dispatched —
94
+ * so providing this does not enable any verb today.
95
+ */
96
+ app?: AppConfig;
97
+ /** Optional secret-store backend selection. Defaults to the systemd-creds provider. */
98
+ secrets?: SecretsConfig;
99
+ }
100
+
101
+ /** Resolves a "cred:<name>" reference to a token, never logging it. */
102
+ export interface SecretStore {
103
+ resolve(ref: string): string;
104
+ }