@mokoconsulting/mokogit-mcp 1.13.4 → 1.14.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.
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: MokoConsulting/mokogit-mcp
17
- VERSION: 00.00.01
17
+ VERSION: 01.14.00
18
18
  PATH: ./CODE_OF_CONDUCT.md
19
19
  BRIEF: Contributor Covenant Code of Conduct version 1.3.0
20
20
  -->
package/CONTRIBUTING.md CHANGED
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: https://git.mokoconsulting.tech/MokoConsulting/mokogit-mcp
17
- VERSION: 00.00.01
17
+ VERSION: 01.14.00
18
18
  PATH: ./CONTRIBUTING.md
19
19
  BRIEF: Contribution guidelines for the project
20
20
  -->
package/Makefile CHANGED
@@ -10,7 +10,7 @@
10
10
  # Identity (auto-filled by workflow sync — the Makefile is the version source of truth)
11
11
  # ==============================================================================
12
12
  PROJECT_NAME := mcp-mokogit
13
- PROJECT_VERSION := 1.13.4
13
+ PROJECT_VERSION := 01.14.00
14
14
  PLATFORM := npm
15
15
 
16
16
  # ==============================================================================
@@ -1,13 +1,15 @@
1
1
  {
2
+ "//": "NO INLINE SECRETS. Point each connection at a token FILE via tokenFile. An inline \"token\" is legacy and is auto-migrated to ~/.ssh/tokens/<host>.token.json on first run (the config is rewritten and a .bak is kept). The token file may hold a raw PAT or a JSON blob {token|access_token, expires_*, scope(s), permissions}; Google service-account/OAuth creds are detected and refused (no token exchange).",
2
3
  "defaultConnection": "moko",
3
4
  "connections": {
4
5
  "moko": {
5
6
  "baseUrl": "https://git.mokoconsulting.tech",
6
- "token": "your-git-access-token"
7
+ "tokenFile": "~/.ssh/tokens/git.mokoconsulting.tech.jmiller.token"
7
8
  },
8
9
  "github-mirror": {
9
10
  "baseUrl": "https://git.example.com",
10
- "token": "your-other-token"
11
+ "tokenFile": "~/.ssh/tokens/git.example.com.token",
12
+ "//token": "legacy: an inline \"token\": \"...\" here still works but is auto-migrated to a tokenFile on first run (no-inline-secrets policy)"
11
13
  }
12
14
  }
13
15
  }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { GitClient } from './client.js';
24
24
  import { setGeneratedConfig, setGeneratedServer, setRegisteredNames, } from './generated-runtime.js';
25
25
  import { registerGeneratedToolsV2 } from './tools.generated.js';
26
26
  import { resolveGuardConfig, runGuardedMerge } from './rapid-merge-guard.js';
27
+ import { probeBackends, warnBackendStatus, runMokoCli, cliRunEnabled, assertSafeArgs } from './mokocli.js';
27
28
  import { investigatePremergeCi, evaluatePremergeGate, renderPremergeReport, } from './premerge-ci.js';
28
29
  let config;
29
30
  function clientFor(connection) {
@@ -252,6 +253,44 @@ function registerTool(name, description, paramsSchema, cb) {
252
253
  }
253
254
  // ── User / Auth ─────────────────────────────────────────────────────────
254
255
  registerTool('git_me', 'Get the authenticated user info', { ...ConnectionParam }, async ({ connection }) => formatResponse(await clientFor(connection).get('/user')));
256
+ // ── Operation backends: mokogit-cli (MokoCLI) — precedence api > mokogit-cli > git ──
257
+ //
258
+ // The REST API is the PRIMARY path: every other tool here calls it directly, and
259
+ // that is what you should use for anything the API covers. The mokogit-cli binary
260
+ // is a FALLBACK backend for CLI-only operations (or when the API is unavailable);
261
+ // plain git is the last resort. These two tools expose that layer.
262
+ registerTool('cli_status', 'Report the MokoGIT MCP operation backends and their precedence (api > mokogit-cli > git): which are available, the resolved mokogit-cli/git binary paths and versions, and the active backend. The REST API is always primary; mokogit-cli and git are fallbacks. Use this to check whether CLI-assisted operations are available on this host.', {}, async () => {
263
+ const status = await probeBackends();
264
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
265
+ });
266
+ registerTool('cli_run', 'Run a mokogit-cli (MokoCLI) subcommand as a FALLBACK backend and return its exit code, stdout and stderr. DISABLED by default — an operator must opt in with $MOKOGIT_ENABLE_CLI_RUN=1 (arbitrary CLI execution). Precedence is api > mokogit-cli > git — prefer the REST API tools for anything they cover; use this only for CLI-only operations or when the API is unavailable. Arguments are passed as argv (no shell); option-like args (leading "-") are rejected unless allowlisted or $MOKOGIT_CLI_ALLOW_FLAGS=1; any cwd must be inside $MOKOGIT_CLI_WORKDIR. Refuses with a clear message when disabled or when mokogit-cli is not installed (check cli_status).', {
267
+ args: z.array(z.string()).describe('mokogit-cli arguments as an argv array, e.g. ["version"] or ["repo","list"]. Passed directly to the binary (no shell).'),
268
+ cwd: z.string().optional().describe('Working directory — must resolve inside $MOKOGIT_CLI_WORKDIR (optional).'),
269
+ timeout_seconds: z.number().int().positive().max(600).optional().describe('Max run time in seconds (default 60).'),
270
+ input: z.string().optional().describe('Optional data to write to the CLI’s stdin.'),
271
+ }, async ({ args, cwd, timeout_seconds, input }) => {
272
+ if (!cliRunEnabled()) {
273
+ return { content: [{ type: 'text', text: 'cli_run is disabled. Arbitrary mokogit-cli execution is an operator opt-in — set $MOKOGIT_ENABLE_CLI_RUN=1 to enable it. The REST API tools are the primary path (precedence api > mokogit-cli > git); use cli_status to see backend availability.' }] };
274
+ }
275
+ try {
276
+ assertSafeArgs(args);
277
+ const r = await runMokoCli(args, {
278
+ cwd,
279
+ timeoutMs: timeout_seconds ? timeout_seconds * 1000 : undefined,
280
+ input,
281
+ });
282
+ const text = [
283
+ `$ ${r.command}`,
284
+ `exit: ${r.code}${r.ok ? '' : ' (non-zero)'}`,
285
+ r.stdout ? `\n--- stdout ---\n${r.stdout.trimEnd()}` : '',
286
+ r.stderr ? `\n--- stderr ---\n${r.stderr.trimEnd()}` : '',
287
+ ].filter(Boolean).join('\n');
288
+ return { content: [{ type: 'text', text }] };
289
+ }
290
+ catch (err) {
291
+ return { content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }] };
292
+ }
293
+ });
255
294
  registerTool('git_user_orgs', 'List organizations the authenticated user belongs to', { ...PaginationParams, ...ConnectionParam }, async ({ page, limit, connection }) => formatResponse(await clientFor(connection).get('/user/orgs', pageQuery({ page, limit }))));
256
295
  registerTool('git_user_repos', 'List repositories owned by the authenticated user', { ...PaginationParams, ...ConnectionParam }, async ({ page, limit, connection }) => formatResponse(await clientFor(connection).get('/user/repos', pageQuery({ page, limit }))));
257
296
  // ── Repository CRUD ─────────────────────────────────────────────────────
@@ -1437,7 +1476,7 @@ registerTool('git_repo_mirror_setup_github_backup', 'One-step GitHub backup mirr
1437
1476
  const client = clientFor(connection);
1438
1477
  const results = [];
1439
1478
  // 1. Try to create the GitHub repo via GitHub API
1440
- const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
1479
+ const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
1441
1480
  method: 'POST',
1442
1481
  headers: {
1443
1482
  'Authorization': `Bearer ${ghToken}`,
@@ -1503,7 +1542,7 @@ registerTool('git_repo_mirror_setup_github_backup_full', 'Full GitHub backup: mi
1503
1542
  const syncInterval = interval ?? '8h0m0s';
1504
1543
  const results = [];
1505
1544
  // 1. Create GitHub repo (if needed)
1506
- const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
1545
+ const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
1507
1546
  method: 'POST',
1508
1547
  headers: {
1509
1548
  'Authorization': `Bearer ${ghToken}`,
@@ -1531,7 +1570,7 @@ registerTool('git_repo_mirror_setup_github_backup_full', 'Full GitHub backup: mi
1531
1570
  return { content: [{ type: 'text', text: `GitHub repo creation failed (${ghRes.status}): ${JSON.stringify(ghData)}` }] };
1532
1571
  }
1533
1572
  // 2. Enable wiki on GitHub repo (in case it was disabled)
1534
- await fetch(`https://api.github.com/repos/${ghOrg}/${repo}`, {
1573
+ await fetch(`https://api.github.com/repos/${enc(ghOrg)}/${enc(repo)}`, {
1535
1574
  method: 'PATCH',
1536
1575
  headers: {
1537
1576
  'Authorization': `Bearer ${ghToken}`,
@@ -1606,7 +1645,7 @@ registerTool('git_admin_cron_list', 'List cron tasks and their last run time (ad
1606
1645
  registerTool('git_admin_cron_run', 'Trigger a cron task (admin only)', {
1607
1646
  task: z.string().describe('Cron task name (e.g. "repo_health_check", "resync_all_hooks", "repo_archive_cleanup")'),
1608
1647
  ...ConnectionParam,
1609
- }, async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${task}`, {})));
1648
+ }, async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${enc(task)}`, {})));
1610
1649
  // ── Generic API Call ────────────────────────────────────────────────────
1611
1650
  registerTool('git_api_request', 'Make a raw API request to any Git v1 endpoint', {
1612
1651
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
@@ -4400,6 +4439,15 @@ async function main() {
4400
4439
  setGeneratedServer(server);
4401
4440
  setRegisteredNames(registeredNames);
4402
4441
  registerGeneratedToolsV2();
4442
+ // Detect the operation backends (API > mokogit-cli > git) and emit a one-time,
4443
+ // non-fatal status line. The REST API is the primary path; mokogit-cli/git are
4444
+ // fallbacks. Detection must never block startup, so swallow any probe error.
4445
+ try {
4446
+ warnBackendStatus(await probeBackends());
4447
+ }
4448
+ catch {
4449
+ /* detection is best-effort; the server runs API-only regardless */
4450
+ }
4403
4451
  const transport = new StdioServerTransport();
4404
4452
  await server.connect(transport);
4405
4453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokoconsulting/mokogit-mcp",
3
- "version": "1.13.4",
3
+ "version": "1.14.0",
4
4
  "description": "MCP server for MokoGIT (Git) REST API v1 operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/source/config.ts CHANGED
@@ -13,10 +13,11 @@
13
13
  * BRIEF: Configuration loader for MokoGIT API MCP connections
14
14
  */
15
15
 
16
- import { readFile, access } from 'node:fs/promises';
16
+ import { readFile, writeFile, rename, access } from 'node:fs/promises';
17
17
  import { resolve } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import type { GitConfig, GitConnection } from './types.js';
20
+ import { resolveTokenValue, readTokenFile, migrateInlineSecret, warn } from './tokenReader.js';
20
21
 
21
22
  // Canonical config filename. Older installs may still use a pre-rename name, which
22
23
  // is read as a fallback so they keep working after the Gitea → MokoGIT rename.
@@ -60,6 +61,42 @@ async function resolveConfigPath(): Promise<string> {
60
61
  return primary;
61
62
  }
62
63
 
64
+ /** Extract a hostname from a baseUrl, or '' when it cannot be parsed. */
65
+ function hostOf(baseUrl: string | undefined): string {
66
+ if (!baseUrl) return '';
67
+ try {
68
+ return new URL(baseUrl).host;
69
+ } catch {
70
+ return '';
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Atomically rewrite the config file after a migration, preserving a leading BOM
76
+ * if the original had one and backing up to <config>.bak once. mode 0600.
77
+ * Throws on I/O failure so the caller can fall back to warn-only.
78
+ */
79
+ async function rewriteConfigAtomic(configPath: string, hadBom: boolean, data: Record<string, unknown>): Promise<void> {
80
+ // One-time backup of the original on-disk file.
81
+ const bak = `${configPath}.bak`;
82
+ try {
83
+ await access(bak);
84
+ // .bak already exists → leave it (first-migration snapshot is the one to keep).
85
+ } catch {
86
+ try {
87
+ const original = await readFile(configPath, 'utf-8');
88
+ await writeFile(bak, original, { mode: 0o600 });
89
+ } catch {
90
+ /* best-effort; absence of a backup must not block the fix */
91
+ }
92
+ }
93
+
94
+ const body = (hadBom ? '' : '') + JSON.stringify(data, null, '\t') + '\n';
95
+ const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
96
+ await writeFile(tmp, body, { mode: 0o600 });
97
+ await rename(tmp, configPath);
98
+ }
99
+
63
100
  export async function loadConfig(): Promise<GitConfig> {
64
101
  const config_path = await resolveConfigPath();
65
102
 
@@ -67,8 +104,12 @@ export async function loadConfig(): Promise<GitConfig> {
67
104
  const raw = await readFile(config_path, 'utf-8');
68
105
  // #192: a config file saved with a UTF-8 BOM makes JSON.parse throw
69
106
  // "Unexpected token" and the MCP fails to start. Strip a leading BOM first.
70
- const clean = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
71
- const parsed = JSON.parse(clean) as Partial<GitConfig>;
107
+ const hadBom = raw.charCodeAt(0) === 0xfeff;
108
+ const clean = hadBom ? raw.slice(1) : raw;
109
+ // Keep the full parsed document (not just the typed view) so a migration
110
+ // rewrite preserves every field we do not touch.
111
+ const parsedFull = JSON.parse(clean) as Record<string, unknown>;
112
+ const parsed = parsedFull as Partial<GitConfig> & Record<string, unknown>;
72
113
 
73
114
  if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
74
115
  throw new Error('No connections defined in config');
@@ -77,9 +118,91 @@ export async function loadConfig(): Promise<GitConfig> {
77
118
  // A token injected via env is the single source of truth — apply it over
78
119
  // whatever the config file holds so a rotated token cannot leave it stale.
79
120
  const envToken = envTokenOverride();
80
- if (envToken) {
81
- for (const conn of Object.values(parsed.connections)) {
82
- conn.token = envToken;
121
+
122
+ // Resolve each connection's credential through the MokoCLI #1043 JSON-aware
123
+ // token ladder, enforcing the no-inline-secrets policy (pass 2).
124
+ //
125
+ // Precedence per connection:
126
+ // 1. env override (MOKOGIT_TOKEN/MOKOGITEA_TOKEN/GITEA_TOKEN) — single source
127
+ // of truth. If the value is an inline literal we WARN only (there is no
128
+ // config line to rewrite) and still use it.
129
+ // 2. conn.tokenFile — file-backed; read with §1b path-safety guards.
130
+ // 3. inline conn.token — WARN + AUTO-MIGRATE to ~/.ssh/tokens/<slug>.token.json,
131
+ // rewrite this config to a tokenFile ref, delete the inline token, and use
132
+ // the migrated file this run. On any migration error, fall back to warn-only
133
+ // and keep running with the inline value (never crash, never lose the cred).
134
+ //
135
+ // A bare PAT round-trips unchanged (back-compat). A Google creds JSON is detected,
136
+ // warned on stderr, and left unusable (empty bearer). A needs-exchange / empty /
137
+ // skipped bearer is allowed to surface at use time (401), not crash at load, so one
138
+ // bad connection cannot take the whole server down. Secrets are never logged.
139
+ //
140
+ // mokogit-mcp sources its config only from $HOME (~/.mcp_mokogit.json) or an
141
+ // explicit $MOKOGIT_MCP_CONFIG / $GITEA_API_MCP_CONFIG override — both TRUSTED
142
+ // sources (§1b). There is NO cwd `.env` loader here, so every tokenFile reference
143
+ // is trusted; we pass trusted=true explicitly.
144
+ // Deep clone of the on-disk document that we mutate for the rewrite ONLY. The
145
+ // runtime objects (parsed.connections[*]) get their `.token` set to the resolved
146
+ // bearer for the HTTP header; the on-disk copy must instead carry a tokenFile ref
147
+ // and NO inline token. Keeping them separate avoids re-leaking the bearer we just
148
+ // assigned to the live object back into the file.
149
+ const rewriteDoc = JSON.parse(JSON.stringify(parsedFull)) as Record<string, unknown>;
150
+ const rewriteConns = rewriteDoc.connections as Record<string, Record<string, unknown>>;
151
+
152
+ let configMutated = false;
153
+ for (const [name, conn] of Object.entries(parsed.connections)) {
154
+ if (envToken) {
155
+ if (typeof conn.token === 'string' && conn.token.trim() && !conn.tokenFile) {
156
+ warn(`connection "${name}" has an inline token in the config AND an env override is set; the env token is used. The inline token is still a policy violation — move it to a tokenFile or remove it.`);
157
+ }
158
+ conn.token = resolveTokenValue(envToken, `env (${name})`).bearer;
159
+ continue;
160
+ }
161
+
162
+ if (conn.tokenFile) {
163
+ conn.token = (await readTokenFile(conn.tokenFile, `${name}.tokenFile`, true)).bearer;
164
+ continue;
165
+ }
166
+
167
+ // Inline literal → warn + migrate.
168
+ const inline = typeof conn.token === 'string' ? conn.token : '';
169
+ if (!inline.trim()) {
170
+ // Empty/missing inline token: surface the empty-credential error as before.
171
+ conn.token = resolveTokenValue(inline, `${name}.token`).bearer;
172
+ continue;
173
+ }
174
+
175
+ const host = hostOf(conn.baseUrl);
176
+ const slug = host || name;
177
+ try {
178
+ const { tokenFile, created } = await migrateInlineSecret(inline, slug, host, 'mokogit');
179
+ if (created) {
180
+ warn(`connection "${name}": inline "token" migrated to a file (no-inline-secrets policy); config rewritten to tokenFile and the inline token removed.`);
181
+ } else {
182
+ warn(`connection "${name}": inline "token" found but ${tokenFile} already exists; reusing it (not overwritten) and rewriting config to a tokenFile ref.`);
183
+ }
184
+ // Rewrite THIS connection in the on-disk clone: set tokenFile, drop inline token.
185
+ const connRewrite = rewriteConns[name];
186
+ connRewrite.tokenFile = tokenFile;
187
+ delete connRewrite.token;
188
+ conn.tokenFile = tokenFile;
189
+ configMutated = true;
190
+ // Use the migrated file this run (path-safety guards apply).
191
+ conn.token = (await readTokenFile(tokenFile, `${name}.tokenFile`, true)).bearer;
192
+ } catch (err) {
193
+ // Migration failed (read-only FS, perms, …): warn-only, keep running inline.
194
+ warn(`connection "${name}": could not migrate inline token to a file (${err instanceof Error ? err.message : String(err)}); continuing with the inline value for this run (fix manually — no-inline-secrets policy).`);
195
+ conn.token = resolveTokenValue(inline, `${name}.token`).bearer;
196
+ }
197
+ }
198
+
199
+ if (configMutated) {
200
+ try {
201
+ await rewriteConfigAtomic(config_path, hadBom, rewriteDoc);
202
+ } catch (err) {
203
+ // The migrated token files are written; only the config rewrite failed.
204
+ // Warn and keep running — the resolved bearers are already in memory.
205
+ warn(`migrated inline token(s) to files but could not rewrite ${config_path} (${err instanceof Error ? err.message : String(err)}); the inline token(s) remain in the config — fix the file manually.`);
83
206
  }
84
207
  }
85
208
 
package/source/index.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from './generated-runtime.js';
32
32
  import { registerGeneratedToolsV2 } from './tools.generated.js';
33
33
  import { resolveGuardConfig, runGuardedMerge } from './rapid-merge-guard.js';
34
+ import { probeBackends, warnBackendStatus, runMokoCli, cliRunEnabled, assertSafeArgs } from './mokocli.js';
34
35
  import {
35
36
  investigatePremergeCi,
36
37
  evaluatePremergeGate,
@@ -297,6 +298,56 @@ registerTool(
297
298
  async ({ connection }) => formatResponse(await clientFor(connection).get('/user')),
298
299
  );
299
300
 
301
+ // ── Operation backends: mokogit-cli (MokoCLI) — precedence api > mokogit-cli > git ──
302
+ //
303
+ // The REST API is the PRIMARY path: every other tool here calls it directly, and
304
+ // that is what you should use for anything the API covers. The mokogit-cli binary
305
+ // is a FALLBACK backend for CLI-only operations (or when the API is unavailable);
306
+ // plain git is the last resort. These two tools expose that layer.
307
+
308
+ registerTool(
309
+ 'cli_status',
310
+ 'Report the MokoGIT MCP operation backends and their precedence (api > mokogit-cli > git): which are available, the resolved mokogit-cli/git binary paths and versions, and the active backend. The REST API is always primary; mokogit-cli and git are fallbacks. Use this to check whether CLI-assisted operations are available on this host.',
311
+ {},
312
+ async () => {
313
+ const status = await probeBackends();
314
+ return { content: [{ type: 'text' as const, text: JSON.stringify(status, null, 2) }] };
315
+ },
316
+ );
317
+
318
+ registerTool(
319
+ 'cli_run',
320
+ 'Run a mokogit-cli (MokoCLI) subcommand as a FALLBACK backend and return its exit code, stdout and stderr. DISABLED by default — an operator must opt in with $MOKOGIT_ENABLE_CLI_RUN=1 (arbitrary CLI execution). Precedence is api > mokogit-cli > git — prefer the REST API tools for anything they cover; use this only for CLI-only operations or when the API is unavailable. Arguments are passed as argv (no shell); option-like args (leading "-") are rejected unless allowlisted or $MOKOGIT_CLI_ALLOW_FLAGS=1; any cwd must be inside $MOKOGIT_CLI_WORKDIR. Refuses with a clear message when disabled or when mokogit-cli is not installed (check cli_status).',
321
+ {
322
+ args: z.array(z.string()).describe('mokogit-cli arguments as an argv array, e.g. ["version"] or ["repo","list"]. Passed directly to the binary (no shell).'),
323
+ cwd: z.string().optional().describe('Working directory — must resolve inside $MOKOGIT_CLI_WORKDIR (optional).'),
324
+ timeout_seconds: z.number().int().positive().max(600).optional().describe('Max run time in seconds (default 60).'),
325
+ input: z.string().optional().describe('Optional data to write to the CLI’s stdin.'),
326
+ },
327
+ async ({ args, cwd, timeout_seconds, input }) => {
328
+ if (!cliRunEnabled()) {
329
+ return { content: [{ type: 'text' as const, text: 'cli_run is disabled. Arbitrary mokogit-cli execution is an operator opt-in — set $MOKOGIT_ENABLE_CLI_RUN=1 to enable it. The REST API tools are the primary path (precedence api > mokogit-cli > git); use cli_status to see backend availability.' }] };
330
+ }
331
+ try {
332
+ assertSafeArgs(args);
333
+ const r = await runMokoCli(args, {
334
+ cwd,
335
+ timeoutMs: timeout_seconds ? timeout_seconds * 1000 : undefined,
336
+ input,
337
+ });
338
+ const text = [
339
+ `$ ${r.command}`,
340
+ `exit: ${r.code}${r.ok ? '' : ' (non-zero)'}`,
341
+ r.stdout ? `\n--- stdout ---\n${r.stdout.trimEnd()}` : '',
342
+ r.stderr ? `\n--- stderr ---\n${r.stderr.trimEnd()}` : '',
343
+ ].filter(Boolean).join('\n');
344
+ return { content: [{ type: 'text' as const, text }] };
345
+ } catch (err) {
346
+ return { content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }] };
347
+ }
348
+ },
349
+ );
350
+
300
351
  registerTool(
301
352
  'git_user_orgs',
302
353
  'List organizations the authenticated user belongs to',
@@ -2048,7 +2099,7 @@ registerTool(
2048
2099
  const results: string[] = [];
2049
2100
 
2050
2101
  // 1. Try to create the GitHub repo via GitHub API
2051
- const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
2102
+ const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
2052
2103
  method: 'POST',
2053
2104
  headers: {
2054
2105
  'Authorization': `Bearer ${ghToken}`,
@@ -2120,7 +2171,7 @@ registerTool(
2120
2171
  const results: string[] = [];
2121
2172
 
2122
2173
  // 1. Create GitHub repo (if needed)
2123
- const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
2174
+ const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
2124
2175
  method: 'POST',
2125
2176
  headers: {
2126
2177
  'Authorization': `Bearer ${ghToken}`,
@@ -2147,7 +2198,7 @@ registerTool(
2147
2198
  }
2148
2199
 
2149
2200
  // 2. Enable wiki on GitHub repo (in case it was disabled)
2150
- await fetch(`https://api.github.com/repos/${ghOrg}/${repo}`, {
2201
+ await fetch(`https://api.github.com/repos/${enc(ghOrg)}/${enc(repo)}`, {
2151
2202
  method: 'PATCH',
2152
2203
  headers: {
2153
2204
  'Authorization': `Bearer ${ghToken}`,
@@ -2275,7 +2326,7 @@ registerTool(
2275
2326
  task: z.string().describe('Cron task name (e.g. "repo_health_check", "resync_all_hooks", "repo_archive_cleanup")'),
2276
2327
  ...ConnectionParam,
2277
2328
  },
2278
- async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${task}`, {})),
2329
+ async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${enc(task)}`, {})),
2279
2330
  );
2280
2331
 
2281
2332
  // ── Generic API Call ────────────────────────────────────────────────────
@@ -7178,6 +7229,14 @@ async function main(): Promise<void> {
7178
7229
  setGeneratedServer(server);
7179
7230
  setRegisteredNames(registeredNames);
7180
7231
  registerGeneratedToolsV2();
7232
+ // Detect the operation backends (API > mokogit-cli > git) and emit a one-time,
7233
+ // non-fatal status line. The REST API is the primary path; mokogit-cli/git are
7234
+ // fallbacks. Detection must never block startup, so swallow any probe error.
7235
+ try {
7236
+ warnBackendStatus(await probeBackends());
7237
+ } catch {
7238
+ /* detection is best-effort; the server runs API-only regardless */
7239
+ }
7181
7240
  const transport = new StdioServerTransport();
7182
7241
  await server.connect(transport);
7183
7242
  }
@@ -0,0 +1,328 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ *
3
+ * This file is part of a Moko Consulting project.
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ *
7
+ * FILE INFORMATION
8
+ * DEFGROUP: mokogit-mcp.MokoCli
9
+ * INGROUP: mokogit-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokogit-mcp
11
+ * PATH: /source/mokocli.ts
12
+ * VERSION: 01.00.00
13
+ * BRIEF: Operation-backend detection + invocation for the MokoGIT MCP.
14
+ * Resolves the mokogit-cli (MokoCLI) binary and plain git at runtime and
15
+ * exposes them as FALLBACK backends behind the REST API. Fleet precedence
16
+ * is API > mokogit-cli > git: the REST API is always the primary path, the
17
+ * CLI is the first fallback, git the last. Detection is non-fatal — when a
18
+ * binary is absent the MCP warns once on stderr and keeps running API-only.
19
+ */
20
+
21
+ import { execFile } from 'node:child_process';
22
+ import { promisify } from 'node:util';
23
+ import { statSync, realpathSync } from 'node:fs';
24
+ import { delimiter, join, isAbsolute, resolve, sep } from 'node:path';
25
+
26
+ const execFileP = promisify(execFile);
27
+ const IS_WIN = process.platform === 'win32';
28
+
29
+ /**
30
+ * Resolve a binary name/path to an ABSOLUTE path by searching $PATH explicitly
31
+ * (like `which`/`where`), honoring PATHEXT on Windows. Returns null when not
32
+ * found. Running the resolved absolute path — never a bare name — prevents
33
+ * cwd-relative / planted-binary resolution (Windows CreateProcess search order).
34
+ */
35
+ function resolveExecutable(nameOrPath: string): string | null {
36
+ const isFile = (p: string): boolean => {
37
+ try { return statSync(p).isFile(); } catch { return false; }
38
+ };
39
+ // Explicit path (absolute or contains a separator): accept only if it is a file.
40
+ // On Windows, refuse a .bat/.cmd target (BatBadBut — argv is not safely escaped
41
+ // when execFile spawns cmd.exe for a batch file), even when set via $MOKOGIT_CLI.
42
+ if (isAbsolute(nameOrPath) || nameOrPath.includes('/') || (IS_WIN && nameOrPath.includes('\\'))) {
43
+ if (IS_WIN && /\.(bat|cmd)$/i.test(nameOrPath)) return null;
44
+ const abs = resolve(nameOrPath);
45
+ return isFile(abs) ? abs : null;
46
+ }
47
+ // Bare name: search PATH directories in order. NEVER include '.' / cwd.
48
+ // On Windows accept ONLY .EXE/.COM — never .BAT/.CMD. Running a batch file via
49
+ // execFile spawns cmd.exe and argv is not safely escaped (BatBadBut / CVE-2024-27980),
50
+ // so a batch target would reintroduce command injection through args. Honor
51
+ // PATHEXT order but intersect it with the exe/com allowlist.
52
+ const WIN_SAFE_EXTS = ['.COM', '.EXE'];
53
+ const exts = IS_WIN
54
+ ? (() => {
55
+ const fromEnv = (process.env.PATHEXT ?? '').split(';').map((e) => e.trim().toUpperCase()).filter((e) => WIN_SAFE_EXTS.includes(e));
56
+ return fromEnv.length ? fromEnv : WIN_SAFE_EXTS;
57
+ })()
58
+ : [''];
59
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter((d) => d && d.trim() && d !== '.');
60
+ for (const dir of dirs) {
61
+ for (const ext of exts) {
62
+ const cand = join(dir, nameOrPath + (IS_WIN ? ext : ''));
63
+ if (isFile(cand)) return resolve(cand);
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Operation backends, in fleet precedence order (highest first). */
70
+ export const BACKEND_PRECEDENCE = ['api', 'mokogit-cli', 'git'] as const;
71
+ export type Backend = (typeof BACKEND_PRECEDENCE)[number];
72
+
73
+ /** Candidate binary names searched on PATH for the MokoCLI, in order. */
74
+ const MOKOCLI_CANDIDATES = ['mokogit-cli', 'mokogit', 'mokocli'];
75
+ /** Env vars that may hold an explicit MokoCLI path (checked before PATH). */
76
+ const MOKOCLI_ENV_VARS = ['MOKOGIT_CLI', 'MOKOCLI', 'MOKOGIT_CLI_PATH'];
77
+
78
+ const VERSION_TIMEOUT_MS = 5000;
79
+
80
+ export interface BinaryProbe {
81
+ available: boolean;
82
+ /** Resolved command (an absolute path from an env var, or a PATH-resolved name). */
83
+ path: string | null;
84
+ /** First line of `--version` output, when it could be read. */
85
+ version: string | null;
86
+ /** How it was resolved: an env var name, 'path', or null when not found. */
87
+ source: string | null;
88
+ }
89
+
90
+ export interface BackendStatus {
91
+ /** Static precedence order (API > mokogit-cli > git). */
92
+ precedence: Backend[];
93
+ /** The REST API is always the primary path; reachability is per-request. */
94
+ api: { available: true; note: string };
95
+ mokogitCli: BinaryProbe & { envVarsChecked: string[]; candidates: string[] };
96
+ git: BinaryProbe;
97
+ /** Available backends in precedence order (api is always present). */
98
+ availableChain: Backend[];
99
+ /** Highest-precedence backend usable for a generic op — always 'api'. */
100
+ active: Backend;
101
+ }
102
+
103
+ /** Run `<bin> <versionArgs>` and return its trimmed first output line, or null. */
104
+ async function readVersion(bin: string, versionArgs: string[]): Promise<string | null> {
105
+ try {
106
+ const { stdout, stderr } = await execFileP(bin, versionArgs, {
107
+ timeout: VERSION_TIMEOUT_MS,
108
+ windowsHide: true,
109
+ });
110
+ const out = (stdout || stderr || '').trim();
111
+ return out ? out.split(/\r?\n/, 1)[0].trim() : '';
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Probe for a binary: try each env var (as an explicit command/path) first, then
119
+ * each PATH candidate, accepting the first whose `--version` runs successfully.
120
+ */
121
+ async function probeBinary(
122
+ envVars: string[],
123
+ candidates: string[],
124
+ versionArgs: string[] = ['--version'],
125
+ ): Promise<BinaryProbe> {
126
+ for (const ev of envVars) {
127
+ const val = process.env[ev];
128
+ if (val && val.trim()) {
129
+ const abs = resolveExecutable(val.trim());
130
+ if (abs) {
131
+ const ver = await readVersion(abs, versionArgs);
132
+ if (ver !== null) return { available: true, path: abs, version: ver || null, source: ev };
133
+ }
134
+ }
135
+ }
136
+ for (const name of candidates) {
137
+ const abs = resolveExecutable(name);
138
+ if (abs) {
139
+ const ver = await readVersion(abs, versionArgs);
140
+ if (ver !== null) return { available: true, path: abs, version: ver || null, source: 'path' };
141
+ }
142
+ }
143
+ return { available: false, path: null, version: null, source: null };
144
+ }
145
+
146
+ /**
147
+ * Build a MINIMAL environment for the child process: only the variables the CLI
148
+ * needs to locate itself, find its home/temp, and authenticate — never the full
149
+ * parent environment. Prevents leaking unrelated secrets into the subprocess.
150
+ */
151
+ function minimalChildEnv(): NodeJS.ProcessEnv {
152
+ const allow = [
153
+ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec',
154
+ 'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
155
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
156
+ // MokoGIT auth / CLI config the CLI may need to operate
157
+ 'MOKOGIT_TOKEN', 'MOKOGITEA_TOKEN', 'GITEA_TOKEN',
158
+ 'MOKOGIT_CLI', 'MOKOCLI', 'MOKOGIT_CLI_PATH',
159
+ 'MOKOGIT_MCP_CONFIG', 'GITEA_API_MCP_CONFIG',
160
+ 'MOKOGIT_HOST', 'MOKOGIT_URL',
161
+ ];
162
+ const env: NodeJS.ProcessEnv = {};
163
+ for (const k of allow) if (process.env[k] !== undefined) env[k] = process.env[k];
164
+ return env;
165
+ }
166
+
167
+ /**
168
+ * Validate a caller-supplied cwd. Running the CLI in an arbitrary directory is a
169
+ * risk (relative config/hook pickup), so a cwd is honored ONLY when it resolves
170
+ * inside the operator-configured workspace root $MOKOGIT_CLI_WORKDIR. Returns the
171
+ * resolved cwd to use, or throws. When no cwd is requested, the configured root
172
+ * (if any) is used; otherwise the child inherits the MCP's own cwd.
173
+ */
174
+ function resolveCwd(requested?: string): string | undefined {
175
+ const rootRaw = process.env.MOKOGIT_CLI_WORKDIR;
176
+ const root = rootRaw && rootRaw.trim() ? realpathSync(resolve(rootRaw.trim())) : null;
177
+ if (!requested) return root ?? undefined;
178
+ if (!root) {
179
+ throw new Error('A cwd was requested but no workspace root is configured. Set $MOKOGIT_CLI_WORKDIR to an allowed directory to use cwd.');
180
+ }
181
+ let abs: string;
182
+ try { abs = realpathSync(resolve(root, requested)); } catch { throw new Error(`cwd does not exist or is not accessible: ${requested}`); }
183
+ if (abs !== root && !abs.startsWith(root + sep)) {
184
+ throw new Error(`cwd must be inside the configured workspace root ($MOKOGIT_CLI_WORKDIR): ${root}`);
185
+ }
186
+ return abs;
187
+ }
188
+
189
+ let cached: Promise<BackendStatus> | null = null;
190
+
191
+ /** Detect the available operation backends. Cached after the first call. */
192
+ export function probeBackends(force = false): Promise<BackendStatus> {
193
+ if (cached && !force) return cached;
194
+ cached = (async (): Promise<BackendStatus> => {
195
+ const [cli, git] = await Promise.all([
196
+ probeBinary(MOKOCLI_ENV_VARS, MOKOCLI_CANDIDATES),
197
+ probeBinary([], ['git']),
198
+ ]);
199
+ const availableChain: Backend[] = ['api'];
200
+ if (cli.available) availableChain.push('mokogit-cli');
201
+ if (git.available) availableChain.push('git');
202
+ return {
203
+ precedence: [...BACKEND_PRECEDENCE],
204
+ api: { available: true, note: 'primary path — all MCP tools call the MokoGIT REST API directly' },
205
+ mokogitCli: { ...cli, envVarsChecked: MOKOCLI_ENV_VARS, candidates: MOKOCLI_CANDIDATES },
206
+ git,
207
+ availableChain,
208
+ active: 'api',
209
+ };
210
+ })();
211
+ return cached;
212
+ }
213
+
214
+ let warned = false;
215
+
216
+ /**
217
+ * Emit a one-time, non-fatal status line to stderr describing backend
218
+ * availability. Absence of the CLI is informational, not an error — the REST API
219
+ * is the primary path and the server runs fine without the CLI.
220
+ */
221
+ export function warnBackendStatus(status: BackendStatus): void {
222
+ if (warned) return;
223
+ warned = true;
224
+ if (status.mokogitCli.available) {
225
+ process.stderr.write(
226
+ `mokogit-cli detected (${status.mokogitCli.path}${status.mokogitCli.version ? ` — ${status.mokogitCli.version}` : ''}); ` +
227
+ `available as a fallback backend. Precedence: api > mokogit-cli > git.\n`,
228
+ );
229
+ } else {
230
+ process.stderr.write(
231
+ `mokogit-cli not found (checked ${MOKOCLI_ENV_VARS.join('/')} and PATH: ${MOKOCLI_CANDIDATES.join(', ')}). ` +
232
+ `CLI-assisted fallback is disabled — operating API-only (precedence api > mokogit-cli > git). ` +
233
+ `Install mokogit-cli or set $MOKOGIT_CLI to enable it.\n`,
234
+ );
235
+ }
236
+ }
237
+
238
+ export interface CliRunResult {
239
+ ok: boolean;
240
+ code: number | null;
241
+ stdout: string;
242
+ stderr: string;
243
+ command: string;
244
+ }
245
+
246
+ const RUN_TIMEOUT_MS = 60000;
247
+ const MAX_BUFFER = 8 * 1024 * 1024;
248
+
249
+ const TRUTHY = /^(1|true|on|yes)$/i;
250
+
251
+ /**
252
+ * Whether the cli_run passthrough is enabled. Arbitrary CLI execution is an
253
+ * explicit operator opt-in and is DISABLED by default — set
254
+ * $MOKOGIT_ENABLE_CLI_RUN=1 to allow it.
255
+ */
256
+ export function cliRunEnabled(): boolean {
257
+ return TRUTHY.test((process.env.MOKOGIT_ENABLE_CLI_RUN ?? '').trim());
258
+ }
259
+
260
+ /** Global flags permitted even though they start with '-'. */
261
+ const ALLOWED_FLAGS = new Set(['-h', '--help', '-v', '--version', '--json']);
262
+
263
+ /**
264
+ * Reject option-like args (leading '-') not in the allowlist, so a default-on
265
+ * cli_run cannot be handed arbitrary flags. Operators who need full flags set
266
+ * $MOKOGIT_CLI_ALLOW_FLAGS=1. Throws with a clear message on a rejected arg.
267
+ */
268
+ export function assertSafeArgs(args: string[]): void {
269
+ if (TRUTHY.test((process.env.MOKOGIT_CLI_ALLOW_FLAGS ?? '').trim())) return;
270
+ for (const a of args) {
271
+ if (a.startsWith('-') && !ALLOWED_FLAGS.has(a)) {
272
+ throw new Error(
273
+ `Refused: option-like argument "${a}" is not allowed by default. ` +
274
+ `Allowed flags: ${[...ALLOWED_FLAGS].join(', ')}. Set $MOKOGIT_CLI_ALLOW_FLAGS=1 to permit arbitrary flags.`,
275
+ );
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Invoke the resolved mokogit-cli with argv (NOT a shell string — no shell
282
+ * interpolation). Throws if the CLI is unavailable; resolves with the exit code,
283
+ * stdout and stderr otherwise (a non-zero exit is returned, not thrown).
284
+ */
285
+ export async function runMokoCli(
286
+ args: string[],
287
+ opts: { cwd?: string; timeoutMs?: number; input?: string } = {},
288
+ ): Promise<CliRunResult> {
289
+ const status = await probeBackends();
290
+ if (!status.mokogitCli.available || !status.mokogitCli.path) {
291
+ throw new Error(
292
+ 'mokogit-cli is not available on this host (checked ' +
293
+ `${MOKOCLI_ENV_VARS.join('/')} and PATH: ${MOKOCLI_CANDIDATES.join(', ')}). ` +
294
+ 'Use the REST API tools (primary path), or install mokogit-cli / set $MOKOGIT_CLI.',
295
+ );
296
+ }
297
+ // status.mokogitCli.path is an ABSOLUTE path resolved at probe time via an
298
+ // explicit PATH search — we never exec a bare name (no cwd-relative/planted
299
+ // binary). cwd is allowlisted to $MOKOGIT_CLI_WORKDIR; the child gets a minimal
300
+ // environment and no shell (execFile passes argv directly).
301
+ const bin = status.mokogitCli.path;
302
+ const cwd = resolveCwd(opts.cwd);
303
+ const command = `${bin} ${args.join(' ')}`;
304
+ try {
305
+ const child = execFileP(bin, args, {
306
+ cwd,
307
+ timeout: opts.timeoutMs ?? RUN_TIMEOUT_MS,
308
+ maxBuffer: MAX_BUFFER,
309
+ windowsHide: true,
310
+ shell: false,
311
+ env: minimalChildEnv(),
312
+ });
313
+ if (opts.input !== undefined && child.child.stdin) {
314
+ child.child.stdin.end(opts.input);
315
+ }
316
+ const { stdout, stderr } = await child;
317
+ return { ok: true, code: 0, stdout: stdout ?? '', stderr: stderr ?? '', command };
318
+ } catch (err) {
319
+ const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
320
+ return {
321
+ ok: false,
322
+ code: typeof e.code === 'number' ? e.code : null,
323
+ stdout: e.stdout ?? '',
324
+ stderr: e.stderr ?? e.message ?? String(err),
325
+ command,
326
+ };
327
+ }
328
+ }
@@ -0,0 +1,231 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ *
4
+ * MokoCLI #1043 — JSON-aware credential/token reader (vendored per-repo).
5
+ * GitHub standard primary; Google secondary = DETECT + WARN (no exchange).
6
+ * Secret values are NEVER written to stdout and NEVER echoed in errors/warnings.
7
+ *
8
+ * Detection ladder (resolveTokenValue):
9
+ * 1. trim; empty -> throw (no silent anonymous request).
10
+ * 2. JSON.parse; parse-fail OR non-object(array/null/scalar) -> the trimmed
11
+ * string IS the bearer (bare PAT, `gh auth token` output). [raw]
12
+ * 3. object .type in {service_account, authorized_user, external_account,
13
+ * impersonated_service_account} OR (.refresh_token && .token_uri)
14
+ * -> GOOGLE: emit a stderr WARNING and return an UNUSABLE result
15
+ * (bearer '', needsExchange true). Never bearer a private key.
16
+ * 4. else .token || .access_token -> bearer (+ expiry/scope/permissions). [github-json]
17
+ * 5. else -> throw (unrecognized object; never leak the blob as a token).
18
+ */
19
+ import { readFile, writeFile, rename, mkdir, stat, access, chmod } from 'node:fs/promises';
20
+ import { homedir } from 'node:os';
21
+ import { resolve, sep } from 'node:path';
22
+
23
+ export type TokenKind = 'raw' | 'github-json' | 'google';
24
+
25
+ export interface ResolvedToken {
26
+ /** Bearer value to send. '' when unusable (Google needs-exchange). */
27
+ bearer: string;
28
+ kind: TokenKind;
29
+ /** true for Google creds — not usable as a bearer by this server. */
30
+ needsExchange?: boolean;
31
+ expiresAt?: string;
32
+ expiresIn?: number;
33
+ scopes?: string[];
34
+ permissions?: Record<string, unknown>;
35
+ }
36
+
37
+ const GOOGLE_TYPES = new Set([
38
+ 'service_account',
39
+ 'authorized_user',
40
+ 'external_account',
41
+ 'impersonated_service_account',
42
+ ]);
43
+
44
+ /** Expand a leading "~" to $HOME, then resolve to an absolute path. */
45
+ export function expandHome(p: string): string {
46
+ if (p === '~') return homedir();
47
+ if (p.startsWith('~/') || p.startsWith('~\\')) return resolve(homedir(), p.slice(2));
48
+ return resolve(p);
49
+ }
50
+
51
+ /** stderr ONLY — must never touch stdout (the MCP stdio channel). Never prints secret values. */
52
+ export function warn(msg: string): void {
53
+ process.stderr.write(`[token] WARNING: ${msg}\n`);
54
+ }
55
+
56
+ /** Largest credential file we will open (§1b path-safety cap). */
57
+ const MAX_TOKEN_FILE_BYTES = 64 * 1024;
58
+
59
+ /** Reduce an arbitrary label to the safe slug charset [A-Za-z0-9._-]. */
60
+ export function sanitizeSlug(s: string): string {
61
+ return (s || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
62
+ }
63
+
64
+ /** True when `abs` is $HOME itself or lives beneath it (no `..` escape). */
65
+ function isWithinHome(abs: string): boolean {
66
+ const home = resolve(homedir());
67
+ return abs === home || abs.startsWith(home + sep);
68
+ }
69
+
70
+ /**
71
+ * Resolve RAW credential content into a bearer per MokoCLI #1043.
72
+ * `source` only labels errors/warnings; secret values are never echoed.
73
+ */
74
+ export function resolveTokenValue(raw: string, source = 'credential'): ResolvedToken {
75
+ const trimmed = (raw ?? '').trim();
76
+ if (!trimmed) {
77
+ throw new Error(`Credential "${source}" is empty — refusing an anonymous request (set the token or remove the connection).`);
78
+ }
79
+
80
+ let obj: unknown;
81
+ try {
82
+ obj = JSON.parse(trimmed);
83
+ } catch {
84
+ return { bearer: trimmed, kind: 'raw' }; // bare PAT / `gh auth token`
85
+ }
86
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
87
+ return { bearer: trimmed, kind: 'raw' };
88
+ }
89
+
90
+ const o = obj as Record<string, unknown>;
91
+ const type = typeof o.type === 'string' ? o.type : '';
92
+ const isGoogle = GOOGLE_TYPES.has(type) || (typeof o.refresh_token === 'string' && typeof o.token_uri === 'string');
93
+ if (isGoogle) {
94
+ warn(`credential "${source}" is a Google ${type || 'OAuth token-cache'} JSON; this server does not perform Google token exchange, so it will NOT be used (a private key is never sent as a bearer token).`);
95
+ return { bearer: '', kind: 'google', needsExchange: true };
96
+ }
97
+
98
+ const bearer = typeof o.token === 'string' ? o.token : typeof o.access_token === 'string' ? o.access_token : '';
99
+ if (bearer) {
100
+ const scopes = Array.isArray(o.scopes)
101
+ ? o.scopes.map(String)
102
+ : typeof o.scope === 'string'
103
+ ? o.scope.split(/\s+/).filter(Boolean)
104
+ : undefined;
105
+ const expiresAt = typeof o.expires_at === 'string' ? o.expires_at : typeof o.expiry === 'string' ? o.expiry : undefined;
106
+ const expiresIn = typeof o.expires_in === 'number' ? o.expires_in : undefined;
107
+ const permissions = o.permissions && typeof o.permissions === 'object' ? (o.permissions as Record<string, unknown>) : undefined;
108
+ return { bearer, kind: 'github-json', expiresAt, expiresIn, scopes, permissions };
109
+ }
110
+
111
+ throw new Error(`Credential "${source}" is an unrecognized JSON object (no "token"/"access_token") — refusing to use it as a bearer token.`);
112
+ }
113
+
114
+ /**
115
+ * Read a token file (~ expands) and resolve it per #1043, with §1b PATH SAFETY.
116
+ *
117
+ * A credential-file reference can otherwise be abused to read an arbitrary local
118
+ * file (e.g. a path pointing at /etc/shadow or ~/.ssh/id_rsa) and exfiltrate it as
119
+ * a bearer to an attacker-controlled target. Only honor a reference from a TRUSTED
120
+ * config source (the process environment or the user's HOME config / explicit
121
+ * $..._CONFIG). mokogit-mcp has NO cwd `.env` loader, so every tokenFile reference
122
+ * here already comes from a trusted source; `trusted` defaults to true and is passed
123
+ * explicitly from config.ts for clarity / future-proofing.
124
+ *
125
+ * Path constraints enforced before the file is opened: regular file, located under
126
+ * $HOME (no absolute-outside-home, no `..` escape), owner-read/write only
127
+ * (mode & 0o077 === 0), size ≤ 64 KiB. On any failure: stderr WARN naming the path
128
+ * + reason and SKIP (return an unusable result) — never read/send its contents,
129
+ * never crash. Files we auto-write (migrateInlineSecret) are 0600 under
130
+ * ~/.ssh/tokens, so they always pass.
131
+ */
132
+ export async function readTokenFile(path: string, source?: string, trusted = true): Promise<ResolvedToken> {
133
+ const abs = expandHome(path);
134
+ const label = source ?? abs;
135
+
136
+ if (!trusted) {
137
+ warn(`refusing credential-file reference "${abs}" from an untrusted source (${label}); a file path is only honored from env or the HOME config — SKIPPING.`);
138
+ return { bearer: '', kind: 'raw', needsExchange: true };
139
+ }
140
+
141
+ if (!isWithinHome(abs)) {
142
+ warn(`credential file "${abs}" (${label}) is outside $HOME — refusing to read it (path-safety) — SKIPPING.`);
143
+ return { bearer: '', kind: 'raw', needsExchange: true };
144
+ }
145
+
146
+ let st: Awaited<ReturnType<typeof stat>>;
147
+ try {
148
+ st = await stat(abs);
149
+ } catch (err) {
150
+ warn(`credential file "${abs}" (${label}) could not be stat'd: ${err instanceof Error ? err.message : String(err)} — SKIPPING.`);
151
+ return { bearer: '', kind: 'raw', needsExchange: true };
152
+ }
153
+ if (!st.isFile()) {
154
+ warn(`credential file "${abs}" (${label}) is not a regular file — SKIPPING.`);
155
+ return { bearer: '', kind: 'raw', needsExchange: true };
156
+ }
157
+ // Owner-only perms: no group/world read/write/execute bits. Windows / MSYS do
158
+ // not model POSIX perms (stat.mode returns synthetic 0666/0444 regardless of the
159
+ // real ACL), so the check is POSIX-only; on win32 it is skipped and the file is
160
+ // trusted on the strength of the within-$HOME + regular-file + size guards.
161
+ if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
162
+ warn(`credential file "${abs}" (${label}) is group/world-accessible (mode ${(st.mode & 0o777).toString(8)}); run \`chmod 600 "${abs}"\` — SKIPPING.`);
163
+ return { bearer: '', kind: 'raw', needsExchange: true };
164
+ }
165
+ if (st.size > MAX_TOKEN_FILE_BYTES) {
166
+ warn(`credential file "${abs}" (${label}) is ${st.size} bytes (> ${MAX_TOKEN_FILE_BYTES} cap) — SKIPPING.`);
167
+ return { bearer: '', kind: 'raw', needsExchange: true };
168
+ }
169
+
170
+ let raw: string;
171
+ try {
172
+ raw = await readFile(abs, 'utf-8');
173
+ } catch (err) {
174
+ warn(`failed to read credential file "${abs}" (${label}): ${err instanceof Error ? err.message : String(err)} — SKIPPING.`);
175
+ return { bearer: '', kind: 'raw', needsExchange: true };
176
+ }
177
+ return resolveTokenValue(raw, label);
178
+ }
179
+
180
+ export interface MigrationResult {
181
+ /** Absolute path of the token file now holding the secret. */
182
+ tokenFile: string;
183
+ /** true when this call created the file; false when an existing file was reused. */
184
+ created: boolean;
185
+ }
186
+
187
+ /**
188
+ * §1 AUTO-MIGRATE an inline secret to ~/.ssh/tokens/<slug>.token.json (self-heal).
189
+ *
190
+ * Writes { token, host, note, migrated_at } at mode 0600 (never overwriting an
191
+ * existing file), and returns the path so the caller can rewrite the config to a
192
+ * tokenFile reference and drop the inline value. The secret is NEVER logged. The
193
+ * caller is responsible for the config rewrite + .bak; this function only owns the
194
+ * token-file side. Throws on I/O failure so the caller can fall back to warn-only.
195
+ */
196
+ export async function migrateInlineSecret(secret: string, slug: string, host: string, serverName: string): Promise<MigrationResult> {
197
+ const tokensDir = resolve(homedir(), '.ssh', 'tokens');
198
+ await mkdir(tokensDir, { recursive: true, mode: 0o700 });
199
+
200
+ const target = resolve(tokensDir, `${sanitizeSlug(slug)}.token.json`);
201
+
202
+ // Do not clobber an existing token file — reuse it instead.
203
+ try {
204
+ await access(target);
205
+ return { tokenFile: target, created: false };
206
+ } catch {
207
+ // does not exist → write it
208
+ }
209
+
210
+ const payload = JSON.stringify(
211
+ {
212
+ token: secret,
213
+ host: host || undefined,
214
+ note: `auto-migrated from inline ${serverName} MCP config (no-inline-secrets policy, MokoCLI #1043)`,
215
+ migrated_at: new Date().toISOString(),
216
+ },
217
+ null,
218
+ '\t',
219
+ );
220
+
221
+ // Atomic-ish: write a sibling temp 0600, then rename into place.
222
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
223
+ await writeFile(tmp, payload + '\n', { mode: 0o600 });
224
+ try {
225
+ await chmod(tmp, 0o600);
226
+ } catch {
227
+ /* best-effort on platforms without POSIX perms */
228
+ }
229
+ await rename(tmp, target);
230
+ return { tokenFile: target, created: true };
231
+ }
package/source/types.ts CHANGED
@@ -16,6 +16,12 @@
16
16
  export interface GitConnection {
17
17
  baseUrl: string;
18
18
  token: string;
19
+ /**
20
+ * Optional path to a file holding the token/credential (MokoCLI #1043).
21
+ * Read via readTokenFile and resolved through the JSON-aware token ladder;
22
+ * "~" expands to $HOME. Takes effect only when no env override is present.
23
+ */
24
+ tokenFile?: string;
19
25
  /** Skip TLS certificate verification (self-signed certs) */
20
26
  insecure?: boolean;
21
27
  }