@cldmv/gh-broker 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
package/src/router.ts DELETED
@@ -1,299 +0,0 @@
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-31T12:12:11-07:00 (1793473931)
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
- import { unavailableReason } from "./availability.ts";
23
- // DEFERRED: link_issue is App-token-only and not wired in — see docs.
24
- // import { linkIssue, type LinkIssueInput } from "./verbs/link_issue.ts";
25
- import {
26
- resolveReviewThread,
27
- replyReviewThread,
28
- type ResolveReviewThreadInput,
29
- type ReplyReviewThreadInput
30
- } from "./verbs/review_thread.ts";
31
- import { reportUnlabeled, type ReportUnlabeledInput } from "./verbs/report_unlabeled.ts";
32
- import { reportSecurityAlerts, type ReportSecurityAlertsInput } from "./verbs/report_security_alerts.ts";
33
- import { reportReleasePrs, type ReportReleasePrsInput } from "./verbs/report_release_prs.ts";
34
- import { issueLabels, type IssueLabelsInput } from "./verbs/issue_labels.ts";
35
- import { reportCiStatus, ciStatusForHeadSha, type ReportCiStatusInput, type FailedJobLogsFetcher } from "./verbs/report_ci_status.ts";
36
- import { npmScaffold } from "./verbs/npm_scaffold.ts";
37
- import { NpmRegistry } from "./verbs/npm-registry.ts";
38
- import { GhcrService } from "./verbs/ghcr_push.ts";
39
-
40
- const GHCR_TOOLS = new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
41
-
42
- /** What the router needs from the upstream (hosted-MCP) client pool. */
43
- export interface Upstream {
44
- forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult>;
45
- }
46
-
47
- /**
48
- * Enforces the default-deny allowlist, resolves the (owner, mode) → PAT, and either
49
- * forwards to the hosted MCP or dispatches a custom verb. The token never leaves here.
50
- */
51
- export class Router {
52
- private readonly config: Config;
53
- private readonly secrets: SecretStore;
54
- private readonly upstream: Upstream;
55
- private readonly appAuth?: AppAuth;
56
- private readonly rules: Map<string, ToolRule>;
57
- /** GHCR verbs run on config.ghcr's own credential, not the owner's GitHub PAT (created on first use). */
58
- private ghcr?: GhcrService;
59
-
60
- constructor(config: Config, secrets: SecretStore, upstream: Upstream, appAuth?: AppAuth) {
61
- this.config = config;
62
- this.secrets = secrets;
63
- this.upstream = upstream;
64
- this.appAuth = appAuth;
65
- this.rules = indexTools(config.tools);
66
- }
67
-
68
- /** Tools advertised to the agent (used to build tools/list). */
69
- advertisedTools(): ToolRule[] {
70
- return this.config.tools.filter((rule) => !unavailableReason(rule, this.config, this.secrets));
71
- }
72
-
73
- async call(tool: string, args: Record<string, unknown>): Promise<CallToolResult> {
74
- const rule = this.rules.get(tool);
75
- if (!rule) {
76
- audit({ tool, decision: "deny", detail: "not in allowlist" });
77
- throw new Error(`tool "${tool}" is not allowed`);
78
- }
79
- // An optional integration that isn't set up hides its tools: refuse them the same way.
80
- const unavailable = unavailableReason(rule, this.config, this.secrets);
81
- if (unavailable) {
82
- audit({ tool, decision: "deny", detail: `unavailable: ${unavailable}` });
83
- throw new Error(`tool "${tool}" is not available on this broker: ${unavailable}`);
84
- }
85
- // The GHCR verbs authenticate with config.ghcr's own credential, never an owner's GitHub
86
- // PAT, so they don't need (or touch) a config.owners entry.
87
- if (GHCR_TOOLS.has(tool)) return this.callGhcr(tool, args, rule);
88
- // Owner-less reads (global advisory DB, get_me, notifications) fall back to
89
- // the configured defaultOwner's READ PAT. Writes never fall back — a write
90
- // must always name the owner it targets.
91
- let owner = extractOwner(args);
92
- if (!owner && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
93
- owner = this.config.defaultOwner.toLowerCase();
94
- }
95
- if (!owner) {
96
- audit({ tool, decision: "error", detail: "no owner in args" });
97
- throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
98
- }
99
- const creds = this.config.owners[owner];
100
- if (!creds) {
101
- audit({ tool, owner, decision: "error", detail: "no creds for owner" });
102
- throw new Error(`no credentials configured for owner "${owner}" — add it to config.owners + creds`);
103
- }
104
- const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
105
- const repo = typeof args.repo === "string" ? `${owner}/${args.repo}` : owner;
106
-
107
- const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
108
- try {
109
- const out = rule.custom
110
- ? await this.dispatchCustom(tool, args, owner, token)
111
- : rule.augment
112
- ? await this.dispatchAugmented(tool, args, owner, token)
113
- : await this.upstream.forward(tool, args, token);
114
- audit({ tool, owner, mode: rule.mode, repo, decision, ok: true });
115
- return out;
116
- } catch (err) {
117
- audit({ tool, owner, mode: rule.mode, repo, decision, ok: false, detail: (err as Error).message });
118
- throw err;
119
- }
120
- }
121
-
122
- /** GHCR verbs: config.ghcr checks the owner and image itself; no GitHub PAT is resolved. */
123
- private async callGhcr(tool: string, args: Record<string, unknown>, rule: ToolRule): Promise<CallToolResult> {
124
- const owner = extractOwner(args) ?? "";
125
- const repo = typeof args.image === "string" ? `${owner}/${args.image}` : owner;
126
- try {
127
- this.ghcr ??= new GhcrService(this.config.ghcr, this.secrets);
128
- const out = tool === "ghcr_push" ? await this.ghcr.push(args) : tool === "ghcr_tags" ? await this.ghcr.tags(args) : this.ghcr.status(args);
129
- audit({ tool, owner, mode: rule.mode, repo, decision: "custom", ok: true });
130
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
131
- } catch (err) {
132
- audit({ tool, owner, mode: rule.mode, repo, decision: "custom", ok: false, detail: (err as Error).message });
133
- throw err;
134
- }
135
- }
136
-
137
- private async dispatchCustom(
138
- tool: string,
139
- args: Record<string, unknown>,
140
- owner: string,
141
- token: string
142
- ): Promise<CallToolResult> {
143
- switch (tool) {
144
- // DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
145
- // Re-enable by restoring this case + the import above + the catalog block, adding a
146
- // link_issue allowlist entry to config, and configuring config.app + owner installationId.
147
- // case "link_issue": {
148
- // const octokit = this.appAuth?.octokitFor(owner);
149
- // if (!octokit) {
150
- // 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)`);
151
- // }
152
- // const out = await linkIssue(octokit, args as unknown as LinkIssueInput);
153
- // return { content: [{ type: "text", text: `linked: PR ${out.pullRequestId} → issue ${out.issueId}` }] };
154
- // }
155
- case "resolve_review_thread": {
156
- // PAT-usable (Pull requests: write) — run on the resolved write PAT, not the App token.
157
- const octokit = new Octokit({ auth: token });
158
- const out = await resolveReviewThread(octokit, args as unknown as ResolveReviewThreadInput);
159
- return { content: [{ type: "text", text: `thread ${out.threadId}: isResolved=${out.isResolved}` }] };
160
- }
161
- case "reply_review_thread": {
162
- const octokit = new Octokit({ auth: token });
163
- const out = await replyReviewThread(octokit, args as unknown as ReplyReviewThreadInput);
164
- return { content: [{ type: "text", text: `replied: ${out.url}` }] };
165
- }
166
- case "issue_labels": {
167
- // Per-label REST endpoints (Issues: write) on the resolved write PAT.
168
- const octokit = new Octokit({ auth: token });
169
- const out = await issueLabels(octokit, args as unknown as IssueLabelsInput);
170
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
171
- }
172
- case "report_unlabeled": {
173
- const octokit = new Octokit({ auth: token });
174
- const out = await reportUnlabeled(octokit, args as unknown as ReportUnlabeledInput);
175
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
176
- }
177
- case "report_security_alerts": {
178
- const octokit = new Octokit({ auth: token });
179
- const out = await reportSecurityAlerts(octokit, args as unknown as ReportSecurityAlertsInput);
180
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
181
- }
182
- case "report_release_prs": {
183
- const octokit = new Octokit({ auth: token });
184
- const out = await reportReleasePrs(octokit, args as unknown as ReportReleasePrsInput);
185
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
186
- }
187
- case "report_ci_status": {
188
- const octokit = new Octokit({ auth: token });
189
- const reportArgs = args as unknown as ReportCiStatusInput;
190
- const out = await reportCiStatus(octokit, reportArgs, this.makeJobLogsFetcher(owner, reportArgs.repo, token));
191
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
192
- }
193
- case "npm_scaffold": {
194
- const npm = this.config.npm!; // availability already required it
195
- // The GitHub side only reads (repo metadata, package.json, workflow file), so it
196
- // runs on the read PAT even though the verb is a write.
197
- const octokit = new Octokit({ auth: this.secrets.resolve(this.config.owners[owner]!.read) });
198
- const registry = new NpmRegistry({ token: this.secrets.resolve(npm.token), ...(npm.registry ? { registry: npm.registry } : {}) });
199
- const out = await npmScaffold(args, { octokit, registry, scopes: npm.scopes });
200
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
201
- }
202
- default:
203
- throw new Error(`custom verb "${tool}" not implemented`);
204
- }
205
- }
206
-
207
- /**
208
- * A failing-run's job logs, via the hosted MCP's own `get_job_logs` tool
209
- * (`failed_only: true` fetches every failing job in the run in one call) rather
210
- * than a hand-rolled REST call — see report_ci_status.ts's FailedJobLogsFetcher
211
- * doc comment for why. Best-effort: any failure here (bad shape, upstream error)
212
- * just yields an empty map — report_ci_status treats a lookup miss as "no
213
- * logTail for this job", never a hard failure of the whole report.
214
- */
215
- private makeJobLogsFetcher(owner: string, repo: string, token: string): FailedJobLogsFetcher {
216
- return async (runId, tailLines) => {
217
- const map = new Map<number, string>();
218
- try {
219
- const result = await this.upstream.forward("get_job_logs", { owner, repo, run_id: runId, failed_only: true, return_content: true, tail_lines: tailLines }, token);
220
- const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
221
- if (!textBlock?.text) return map;
222
- const parsed = JSON.parse(textBlock.text) as { logs?: Array<{ job_id?: unknown; logs_content?: unknown }> };
223
- for (const entry of parsed.logs ?? []) {
224
- if (typeof entry.job_id === "number" && typeof entry.logs_content === "string") map.set(entry.job_id, entry.logs_content);
225
- }
226
- } catch {
227
- /* best-effort — see doc comment */
228
- }
229
- return map;
230
- };
231
- }
232
-
233
- /**
234
- * Forwards to the hosted MCP like a plain proxy, then post-processes the result.
235
- * gh-broker presents its own MCP contract — a proxied tool's response is not
236
- * required to match the hosted MCP's shape verbatim (see [[broker-owns-its-mcp-contract]]
237
- * in project memory). `ciVerbosity`/`logLines` are gh-broker's own extension
238
- * params, stripped before forwarding since the upstream schema doesn't know them.
239
- *
240
- * Opt-in only: with no `ciVerbosity`, this is byte-identical to a plain forward —
241
- * augmenting every `pull_request_read` call by default would add an unrequested
242
- * API call (and cost) to every ordinary PR read.
243
- *
244
- * Best-effort: if the upstream response isn't the JSON-text shape expected (a
245
- * format this broker doesn't control), the original result is returned unmodified
246
- * rather than thrown — a shape mismatch should degrade to "no CI data added," not
247
- * break an otherwise-successful PR read.
248
- */
249
- private async dispatchAugmented(tool: string, args: Record<string, unknown>, owner: string, token: string): Promise<CallToolResult> {
250
- switch (tool) {
251
- case "pull_request_read": {
252
- const { ciVerbosity, logLines, ...upstreamArgs } = args as Record<string, unknown> & { ciVerbosity?: number; logLines?: number };
253
- const result = await this.upstream.forward(tool, upstreamArgs, token);
254
- if (ciVerbosity === undefined) return result;
255
- if ((upstreamArgs.method ?? "get") !== "get") return result; // only the single-PR "get" shape is augmentable
256
-
257
- const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
258
- if (!textBlock?.text) return result;
259
- let pr: unknown;
260
- try {
261
- pr = JSON.parse(textBlock.text);
262
- } catch {
263
- return result;
264
- }
265
- const p = pr as { number?: unknown; head?: { sha?: unknown } };
266
- if (typeof p.number !== "number" || typeof p.head?.sha !== "string") return result;
267
-
268
- const repo = String(upstreamArgs.repo ?? "");
269
- const octokit = new Octokit({ auth: token });
270
- const ci = await ciStatusForHeadSha(octokit, owner, repo, p.number, p.head.sha, {
271
- verbosity: Number(ciVerbosity),
272
- logLines: logLines !== undefined ? Number(logLines) : undefined,
273
- fetchFailedJobLogs: this.makeJobLogsFetcher(owner, repo, token)
274
- });
275
- textBlock.text = JSON.stringify({ ...(pr as object), ci });
276
- return result;
277
- }
278
- default:
279
- return this.upstream.forward(tool, args, token);
280
- }
281
- }
282
- }
283
-
284
- /** Derive the repo owner from a tool call's arguments, so we pick the right PAT. */
285
- export function extractOwner(args: Record<string, unknown>): string | undefined {
286
- if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
287
- // Many org-scoped tools (list_org_repository_security_advisories, get_team_members, …)
288
- // name the org as `org` rather than `owner`; for routing, org === owner.
289
- if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
290
- if (typeof args.query === "string") {
291
- const m = args.query.match(/\brepo:([^/\s]+)\/[^\s]+|\b(?:org|user):([^\s]+)/i);
292
- const hit = m?.[1] ?? m?.[2];
293
- if (hit) return hit.toLowerCase();
294
- }
295
- if (typeof args.repository === "string" && args.repository.includes("/")) {
296
- return args.repository.split("/")[0]?.toLowerCase();
297
- }
298
- return undefined;
299
- }
package/src/secrets.ts DELETED
@@ -1,167 +0,0 @@
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-31T12:12:11-07:00 (1793473931)
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
- import { ghcrCredRefs } from "./verbs/ghcr/config.ts";
21
-
22
- const CRED_PREFIX = "cred:";
23
-
24
- /**
25
- * Loads PATs from the configured secret store and returns a resolver mapping
26
- * "cred:<name>" refs to token values. Never writes or logs a token.
27
- *
28
- * Provider seam (chosen at install time, recorded in config.secrets.provider):
29
- * - "systemd-creds" (default) — tokens are systemd-encrypted, host/TPM-bound
30
- * `.cred` files delivered via LoadCredentialEncrypted into $CREDENTIALS_DIRECTORY
31
- * (memory-only, service-user-readable). Encrypted at rest; not portable.
32
- * - "file" — a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path.
33
- * Plaintext at rest (perms only); portable and platform-neutral.
34
- *
35
- * Both keep tokens off any agent-readable path — the agent boundary is filesystem
36
- * perms either way. See docs/deploy-architecture.md.
37
- */
38
- export function loadSecretStore(config: Config): SecretStore {
39
- const provider = config.secrets?.provider ?? "systemd-creds";
40
- switch (provider) {
41
- case "systemd-creds":
42
- return systemdCredsStore(config);
43
- case "file":
44
- return fileStore(config);
45
- default:
46
- throw new Error(`secrets: unknown provider "${provider}" (expected "systemd-creds" or "file")`);
47
- }
48
- }
49
-
50
- const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
51
-
52
- /** A store over a reader that throws for a missing/empty credential. */
53
- function storeFrom(read: (name: string) => string): SecretStore {
54
- return {
55
- resolve: (ref) => read(credName(ref)),
56
- has: (ref) => {
57
- const name = credName(ref);
58
- try {
59
- read(name);
60
- return true;
61
- } catch {
62
- return false;
63
- }
64
- }
65
- };
66
- }
67
-
68
- /**
69
- * Credential names become filesystem path components (`join(dir, name)`) and are
70
- * interpolated into systemd drop-in lines, so constrain them to a safe charset —
71
- * no path separators, "..", colons, or control characters — to prevent reads
72
- * outside $CREDENTIALS_DIRECTORY and malformed drop-ins.
73
- */
74
- function assertSafeName(name: string): string {
75
- if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
76
- throw new Error(`secrets: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
77
- }
78
- return name;
79
- }
80
-
81
- function credName(ref: string): string {
82
- if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: bad credential ref "${ref}"`);
83
- return assertSafeName(ref.slice(CRED_PREFIX.length));
84
- }
85
-
86
- /**
87
- * Eagerly read every REQUIRED credential (the owners' PATs, the App key) so a missing token
88
- * fails at startup, not on first request. Optional credentials (the npm token, the GHCR
89
- * credentials) are only
90
- * format-checked here: when one isn't stored, the tools that need it are hidden instead
91
- * (see availability.ts), and the rest of the broker runs normally.
92
- */
93
- function validateRefs(config: Config, read: (name: string) => string): void {
94
- for (const [owner, creds] of Object.entries(config.owners)) {
95
- for (const ref of [creds.read, creds.write]) {
96
- if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: owner "${owner}" ref must be "cred:<name>", got "${ref}"`);
97
- read(assertSafeName(ref.slice(CRED_PREFIX.length)));
98
- }
99
- }
100
- if (config.app) {
101
- // The App key is a secret too — it must be a cred: ref, never inlined in the
102
- // token-free config. Reject a non-cred ref rather than silently accepting it.
103
- if (!config.app.privateKey.startsWith(CRED_PREFIX)) {
104
- throw new Error(`secrets: config.app.privateKey must be a "cred:<name>" ref, got "${config.app.privateKey}"`);
105
- }
106
- read(assertSafeName(config.app.privateKey.slice(CRED_PREFIX.length)));
107
- }
108
- if (config.npm) {
109
- if (!config.npm.token.startsWith(CRED_PREFIX)) throw new Error(`secrets: config.npm.token must be a "cred:<name>" ref, got "${config.npm.token}"`);
110
- assertSafeName(config.npm.token.slice(CRED_PREFIX.length));
111
- }
112
- // GHCR credentials are optional too: format-checked here, and the ghcr_* tools are hidden
113
- // while none is stored (availability.ts).
114
- for (const ref of ghcrCredRefs(config.ghcr)) credName(ref);
115
- }
116
-
117
- /** systemd-creds provider: read decrypted creds from $CREDENTIALS_DIRECTORY. */
118
- function systemdCredsStore(config: Config): SecretStore {
119
- const dir = process.env.CREDENTIALS_DIRECTORY;
120
- if (!dir) {
121
- throw new Error("secrets: $CREDENTIALS_DIRECTORY unset — the systemd-creds provider needs LoadCredentialEncrypted= (run under systemd)");
122
- }
123
- const cache = new Map<string, string>();
124
- const read = (name: string): string => {
125
- const cached = cache.get(name);
126
- if (cached !== undefined) return cached;
127
- let val: string;
128
- try {
129
- val = readFileSync(join(dir, name), "utf8").replace(/\r?\n$/, "");
130
- } catch (err) {
131
- throw new Error(`secrets: credential "${name}" not available: ${(err as Error).message}`);
132
- }
133
- if (!val) throw new Error(`secrets: credential "${name}" is empty`);
134
- cache.set(name, val);
135
- return val;
136
- };
137
- validateRefs(config, read);
138
- return storeFrom(read);
139
- }
140
-
141
- /** file provider: read tokens from a JSON `{ "<name>": "<token>" }` map. */
142
- function fileStore(config: Config): SecretStore {
143
- const path = resolveSecretsPath(config.secrets?.path);
144
- let raw: unknown;
145
- try {
146
- raw = JSON.parse(readFileSync(path, "utf8"));
147
- } catch (err) {
148
- throw new Error(`secrets: cannot read/parse secrets file ${path}: ${(err as Error).message}`);
149
- }
150
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
151
- throw new Error(`secrets: ${path} must be a JSON object of { "<name>": "<token>" }`);
152
- }
153
- const map = raw as Record<string, unknown>; // read once into memory; never re-read or write
154
-
155
- const read = (name: string): string => {
156
- const val = map[name];
157
- if (typeof val !== "string") throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
158
- // Normalize like the systemd-creds provider: strip a single trailing newline
159
- // (common when a token is copy/pasted into the JSON) so it isn't silently
160
- // mismatched at auth time. A value that is only a newline then reads as empty.
161
- const token = val.replace(/\r?\n$/, "");
162
- if (token.length === 0) throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
163
- return token;
164
- };
165
- validateRefs(config, read);
166
- return storeFrom(read);
167
- }
package/src/server.ts DELETED
@@ -1,91 +0,0 @@
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 DELETED
@@ -1,128 +0,0 @@
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-31T12:12:12-07:00 (1793473932)
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
- import type { GhcrConfig } from "./verbs/ghcr/config.ts";
27
-
28
- export type Mode = "read" | "write";
29
-
30
- /**
31
- * One entry in the default-deny allowlist. `custom` verbs are handled locally, not
32
- * proxied. `augment` tools ARE proxied (forwarded to the hosted MCP) but the router
33
- * post-processes the response afterward — gh-broker's own MCP contract for a proxied
34
- * tool is not required to match the hosted MCP's exact schema/response verbatim (see
35
- * router.ts's dispatchAugmented). `custom` and `augment` are mutually exclusive.
36
- */
37
- export interface ToolRule {
38
- name: string;
39
- mode: Mode;
40
- custom?: boolean;
41
- augment?: boolean;
42
- }
43
-
44
- /** Credential *references* (e.g. "cred:cldmv-read"), resolved via the SecretStore. */
45
- export interface OwnerCreds {
46
- read: string;
47
- write: string;
48
- /**
49
- * GitHub App installation id for this owner. Required only for verbs that need
50
- * an App token (currently `link_issue` → addClosingIssueReference, which is not
51
- * available to fine-grained PATs). Omit if the owner has no App installed.
52
- */
53
- installationId?: number;
54
- }
55
-
56
- /** GitHub App config — for App-token-only operations (`link_issue`). Optional. */
57
- export interface AppConfig {
58
- appId: number;
59
- /** Credential reference (e.g. "cred:gh-app-key") for the App's PEM private key. */
60
- privateKey: string;
61
- }
62
-
63
- /**
64
- * Where the actual token values live — chosen at install time. A provider seam:
65
- * - "systemd-creds" (default): encrypted, host-bound `.cred` files via
66
- * LoadCredentialEncrypted → $CREDENTIALS_DIRECTORY (encrypted at rest, not portable).
67
- * - "file": a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path
68
- * (plaintext at rest, portable, platform-neutral).
69
- * Further providers (OS keychain, DPAPI, encrypted vault) can be added here
70
- * without touching call sites. Omit to use the systemd-creds provider.
71
- */
72
- export interface SecretsConfig {
73
- provider?: "systemd-creds" | "file";
74
- /** For provider "file": path to the JSON secrets file. Defaults to <configDir>/secrets.json. */
75
- path?: string;
76
- }
77
-
78
- /**
79
- * npm integration for `npm_scaffold`. Optional: without it the verb refuses. The token is a
80
- * granular access token created by the installer without 2FA bypass, so every write it makes
81
- * still needs a user-supplied one-time password.
82
- */
83
- export interface NpmConfig {
84
- /** Credential reference (e.g. "cred:npm-token") for the granular access token. */
85
- token: string;
86
- /** Scopes the verb may create packages in, e.g. ["@cldmv"]. */
87
- scopes: string[];
88
- /** Registry base URL. Defaults to https://registry.npmjs.org. */
89
- registry?: string;
90
- /** Installer only: npm orgs the token is also granted org read-write on when it is created. */
91
- tokenOrgs?: string[];
92
- }
93
-
94
- export interface Config {
95
- /** e.g. "unix:/run/gh-broker/gh-broker.sock" */
96
- listen: string;
97
- /** GitHub hosted MCP base URL, e.g. "https://api.githubcopilot.com/mcp/" */
98
- upstream: string;
99
- tools: ToolRule[];
100
- owners: Record<string, OwnerCreds>;
101
- /**
102
- * Owner whose READ PAT handles calls that carry no owner in their args —
103
- * the genuinely global/identity reads (global security-advisory DB, `get_me`,
104
- * `list_notifications`). Must be a key of `owners`. Applies to `mode: "read"`
105
- * only; write calls always require an explicit owner. Omit to reject every
106
- * owner-less call (the stricter original behaviour).
107
- */
108
- defaultOwner?: string;
109
- /**
110
- * Optional GitHub App, reserved for `link_issue` (an App-token-only mutation).
111
- * `link_issue` is currently deferred — it is neither advertised nor dispatched —
112
- * so providing this does not enable any verb today.
113
- */
114
- app?: AppConfig;
115
- /** Optional secret-store backend selection. Defaults to the systemd-creds provider. */
116
- secrets?: SecretsConfig;
117
- /** Optional npm integration (`npm_scaffold`). */
118
- npm?: NpmConfig;
119
- /** Optional GHCR push policy + credentials for the ghcr_* verbs (src/verbs/ghcr/config.ts). */
120
- ghcr?: GhcrConfig;
121
- }
122
-
123
- /** Resolves a "cred:<name>" reference to a token, never logging it. */
124
- export interface SecretStore {
125
- resolve(ref: string): string;
126
- /** Whether an optional credential is stored (non-empty). Never throws for a missing one. */
127
- has(ref: string): boolean;
128
- }