@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.
- package/.configs/fix-headers.json +3 -0
- package/.configs/vitest.config.mjs +44 -0
- package/.github/dependabot.yml +111 -0
- package/.github/workflows/branch-retention.yml +44 -0
- package/.github/workflows/bundle-size.yml +52 -0
- package/.github/workflows/ci.yml +389 -0
- package/.github/workflows/cla.yml +66 -0
- package/.github/workflows/codeql.yml +87 -0
- package/.github/workflows/dependabot-auto-merge.yml +62 -0
- package/.github/workflows/dependabot-recreate.yml +67 -0
- package/.github/workflows/dependency-review.yml +46 -0
- package/.github/workflows/feature-pr.yml +75 -0
- package/.github/workflows/hotfix-redirector.yml +67 -0
- package/.github/workflows/hotfixes-release.yml +70 -0
- package/.github/workflows/labeler.yml +48 -0
- package/.github/workflows/master-commit-audit.yml +56 -0
- package/.github/workflows/member-auto-merge.yml +75 -0
- package/.github/workflows/next-release.yml +76 -0
- package/.github/workflows/next-reset.yml +50 -0
- package/.github/workflows/pr-notify.yml +44 -0
- package/.github/workflows/pr-title-normalizer.yml +52 -0
- package/.github/workflows/provenance.yml +51 -0
- package/.github/workflows/publish.yml +146 -0
- package/.github/workflows/release-merge.yml +104 -0
- package/.github/workflows/release-notify.yml +41 -0
- package/.github/workflows/scorecard.yml +64 -0
- package/.github/workflows/stale.yml +50 -0
- package/.github/workflows/tag-health.yml +70 -0
- package/.github/workflows/update-major-version-tags.yml +94 -0
- package/.github/workflows/v4-bootstrap.yml +115 -0
- package/.github/workflows/welcome.yml +42 -0
- package/LICENSE +202 -0
- package/README.md +185 -2
- package/config.example.json +281 -0
- package/deploy/README.md +92 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +344 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +56 -0
- package/deploy/lib/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +114 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +222 -0
- package/dist/index.mjs +22970 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +347 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/deploy-architecture.md +82 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/catalog.ts +165 -0
- package/src/config.ts +130 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +258 -0
- package/src/secrets.ts +137 -0
- package/src/server.ts +91 -0
- package/src/types.ts +104 -0
- package/src/upstream.ts +85 -0
- package/src/verbs/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -0
- package/src/verbs/release-analysis.ts +210 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +180 -0
- package/tests/config-merge.test.vitest.mts +58 -0
- package/tests/config.test.vitest.mts +314 -0
- package/tests/issue-labels.test.vitest.mts +347 -0
- package/tests/paths.test.vitest.mts +52 -0
- package/tests/release-prs.test.vitest.mts +237 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +399 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +106 -0
- package/tests/secrets.test.vitest.mts +155 -0
- package/tests/security-alerts.test.vitest.mts +328 -0
- package/tests/server.test.vitest.mts +20 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +331 -0
- package/tests/verbs.test.vitest.mts +624 -0
- 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
|
+
}
|