safe-push 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-push",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Git push safety checker - blocks pushes to forbidden areas",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,9 +9,16 @@
9
9
  "scripts": {
10
10
  "build": "bun build ./src/index.ts --outdir ./dist --target bun",
11
11
  "typecheck": "tsc --noEmit",
12
- "dev": "bun run ./src/index.ts"
12
+ "dev": "bun run ./src/index.ts",
13
+ "generate-schema": "bun run ./scripts/generate-schema.ts"
13
14
  },
14
15
  "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.27.1",
17
+ "@opentelemetry/api": "^1.9.0",
18
+ "@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
19
+ "@opentelemetry/resources": "^2.5.1",
20
+ "@opentelemetry/sdk-trace-base": "^2.5.1",
21
+ "@opentelemetry/semantic-conventions": "^1.39.0",
15
22
  "commander": "^12.1.0",
16
23
  "jsonc-parser": "^3.3.1",
17
24
  "zod": "^3.23.8"
@@ -19,6 +26,7 @@
19
26
  "devDependencies": {
20
27
  "@types/bun": "latest",
21
28
  "@types/node": "^22.10.0",
22
- "typescript": "^5.7.2"
29
+ "typescript": "^5.7.2",
30
+ "zod-to-json-schema": "^3.25.1"
23
31
  }
24
32
  }
@@ -0,0 +1,13 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { zodToJsonSchema } from "zod-to-json-schema";
4
+ import { ConfigSchema } from "../src/types";
5
+
6
+ const jsonSchema = zodToJsonSchema(ConfigSchema, {
7
+ name: "SafePushConfig",
8
+ $refStrategy: "none",
9
+ });
10
+
11
+ const outPath = path.join(import.meta.dirname, "..", "config.schema.json");
12
+ fs.writeFileSync(outPath, JSON.stringify(jsonSchema, null, 2) + "\n", "utf-8");
13
+ console.log(`Generated ${outPath}`);
package/src/checker.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getDiffFiles,
8
8
  getRepoVisibility,
9
9
  } from "./git";
10
+ import { withSpan } from "./telemetry";
10
11
 
11
12
  /**
12
13
  * Visibility チェック結果
@@ -24,20 +25,27 @@ export interface VisibilityCheckResult {
24
25
  export async function checkVisibility(
25
26
  allowedVisibility?: RepoVisibility[]
26
27
  ): Promise<VisibilityCheckResult | null> {
27
- if (!allowedVisibility || allowedVisibility.length === 0) {
28
- return null;
29
- }
28
+ return withSpan("safe-push.check.visibility", async (span) => {
29
+ if (!allowedVisibility || allowedVisibility.length === 0) {
30
+ return null;
31
+ }
32
+
33
+ const visibility = await getRepoVisibility();
34
+ const allowed = allowedVisibility.includes(visibility as RepoVisibility);
30
35
 
31
- const visibility = await getRepoVisibility();
32
- const allowed = allowedVisibility.includes(visibility as RepoVisibility);
36
+ span.addEvent("visibility.result", {
37
+ value: visibility,
38
+ allowed,
39
+ });
33
40
 
34
- return {
35
- allowed,
36
- reason: allowed
37
- ? `Repository visibility "${visibility}" is allowed`
38
- : `Repository visibility "${visibility}" is not in allowed list: [${allowedVisibility.join(", ")}]`,
39
- visibility,
40
- };
41
+ return {
42
+ allowed,
43
+ reason: allowed
44
+ ? `Repository visibility "${visibility}" is allowed`
45
+ : `Repository visibility "${visibility}" is not in allowed list: [${allowedVisibility.join(", ")}]`,
46
+ visibility,
47
+ };
48
+ });
41
49
  }
42
50
 
43
51
  /**
@@ -88,58 +96,69 @@ function findForbiddenFiles(
88
96
  * (禁止エリア変更なし) AND (新規ブランチ OR 最終コミットが自分)
89
97
  */
90
98
  export async function checkPush(config: Config): Promise<CheckResult> {
91
- const currentBranch = await getCurrentBranch();
92
- const newBranch = await isNewBranch();
93
- const authorEmail = await getLastCommitAuthorEmail();
94
- const localEmail = await getLocalEmail();
95
- const diffFiles = await getDiffFiles();
99
+ return withSpan("safe-push.check.push", async (span) => {
100
+ const currentBranch = await getCurrentBranch();
101
+ const newBranch = await isNewBranch();
102
+ const authorEmail = await getLastCommitAuthorEmail();
103
+ const localEmail = await getLocalEmail();
104
+ const diffFiles = await getDiffFiles();
96
105
 
97
- const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
98
- const hasForbiddenChanges = forbiddenFiles.length > 0;
99
- const isOwnLastCommit =
100
- authorEmail.toLowerCase() === localEmail.toLowerCase();
106
+ const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
107
+ const hasForbiddenChanges = forbiddenFiles.length > 0;
108
+ const isOwnLastCommit =
109
+ authorEmail.toLowerCase() === localEmail.toLowerCase();
101
110
 
102
- const details = {
103
- isNewBranch: newBranch,
104
- isOwnLastCommit,
105
- hasForbiddenChanges,
106
- forbiddenFiles,
107
- currentBranch,
108
- authorEmail,
109
- localEmail,
110
- };
111
-
112
- // 禁止エリアに変更がある場合は常にブロック
113
- if (hasForbiddenChanges) {
114
- return {
115
- allowed: false,
116
- reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
117
- details,
111
+ const details = {
112
+ isNewBranch: newBranch,
113
+ isOwnLastCommit,
114
+ hasForbiddenChanges,
115
+ forbiddenFiles,
116
+ currentBranch,
117
+ authorEmail,
118
+ localEmail,
118
119
  };
119
- }
120
120
 
121
- // 新規ブランチの場合は許可
122
- if (newBranch) {
123
- return {
124
- allowed: true,
125
- reason: "New branch - no restrictions",
126
- details,
127
- };
128
- }
121
+ let result: CheckResult;
129
122
 
130
- // 最終コミットが自分の場合は許可
131
- if (isOwnLastCommit) {
132
- return {
133
- allowed: true,
134
- reason: "Last commit is yours",
135
- details,
136
- };
137
- }
123
+ // 禁止エリアに変更がある場合は常にブロック
124
+ if (hasForbiddenChanges) {
125
+ result = {
126
+ allowed: false,
127
+ reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
128
+ details,
129
+ };
130
+ } else if (newBranch) {
131
+ // 新規ブランチの場合は許可
132
+ result = {
133
+ allowed: true,
134
+ reason: "New branch - no restrictions",
135
+ details,
136
+ };
137
+ } else if (isOwnLastCommit) {
138
+ // 最終コミットが自分の場合は許可
139
+ result = {
140
+ allowed: true,
141
+ reason: "Last commit is yours",
142
+ details,
143
+ };
144
+ } else {
145
+ // それ以外はブロック
146
+ result = {
147
+ allowed: false,
148
+ reason: `Last commit is by someone else (${authorEmail})`,
149
+ details,
150
+ };
151
+ }
152
+
153
+ span.addEvent("check.result", {
154
+ allowed: result.allowed,
155
+ reason: result.reason,
156
+ isNewBranch: newBranch,
157
+ isOwnLastCommit,
158
+ hasForbiddenChanges,
159
+ forbiddenFileCount: forbiddenFiles.length,
160
+ });
138
161
 
139
- // それ以外はブロック
140
- return {
141
- allowed: false,
142
- reason: `Last commit is by someone else (${authorEmail})`,
143
- details,
144
- };
162
+ return result;
163
+ });
145
164
  }
@@ -3,6 +3,8 @@ import { loadConfig } from "../config";
3
3
  import { checkPush, checkVisibility } from "../checker";
4
4
  import { isGitRepository, hasCommits } from "../git";
5
5
  import { printError, printCheckResultJson, printCheckResultHuman } from "./utils";
6
+ import { ExitError } from "../types";
7
+ import { withSpan } from "../telemetry";
6
8
 
7
9
  /**
8
10
  * checkコマンドを作成
@@ -12,20 +14,27 @@ export function createCheckCommand(): Command {
12
14
  .description("Check if push is allowed")
13
15
  .option("--json", "Output result as JSON")
14
16
  .action(async (options: { json?: boolean }) => {
15
- try {
17
+ await withSpan("safe-push.check", async (rootSpan) => {
16
18
  // Gitリポジトリ内か確認
17
19
  if (!(await isGitRepository())) {
18
20
  printError("Not a git repository");
19
- process.exit(1);
21
+ throw new ExitError(1);
20
22
  }
21
23
 
22
24
  // コミットが存在するか確認
23
25
  if (!(await hasCommits())) {
24
26
  printError("No commits found");
25
- process.exit(1);
27
+ throw new ExitError(1);
26
28
  }
27
29
 
28
30
  const config = loadConfig();
31
+
32
+ rootSpan.addEvent("config.loaded", {
33
+ forbiddenPaths: JSON.stringify(config.forbiddenPaths),
34
+ onForbidden: config.onForbidden,
35
+ hasVisibilityRule: !!(config.allowedVisibility && config.allowedVisibility.length > 0),
36
+ });
37
+
29
38
  const result = await checkPush(config);
30
39
 
31
40
  // visibility チェック
@@ -41,6 +50,7 @@ export function createCheckCommand(): Command {
41
50
  }
42
51
  }
43
52
  } catch (error) {
53
+ if (error instanceof ExitError) throw error;
44
54
  result.details.repoVisibility = "unknown";
45
55
  result.details.visibilityAllowed = false;
46
56
  result.allowed = false;
@@ -54,12 +64,9 @@ export function createCheckCommand(): Command {
54
64
  printCheckResultHuman(result);
55
65
  }
56
66
 
57
- process.exit(result.allowed ? 0 : 1);
58
- } catch (error) {
59
- printError(
60
- `Check failed: ${error instanceof Error ? error.message : String(error)}`
61
- );
62
- process.exit(1);
63
- }
67
+ if (!result.allowed) {
68
+ throw new ExitError(1);
69
+ }
70
+ });
64
71
  });
65
72
  }
@@ -0,0 +1,383 @@
1
+ import { Command } from "commander";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
+ import {
7
+ createServer,
8
+ type IncomingMessage,
9
+ type ServerResponse,
10
+ } from "node:http";
11
+ import { randomUUID } from "node:crypto";
12
+ import { z } from "zod";
13
+ import * as path from "node:path";
14
+ import { loadConfig } from "../config";
15
+ import { checkPush, checkVisibility } from "../checker";
16
+ import { isGitRepository, hasCommits, execPush } from "../git";
17
+
18
+ function createMcpServer(): McpServer {
19
+ const server = new McpServer({
20
+ name: "safe-push",
21
+ version: "0.3.0",
22
+ });
23
+
24
+ server.tool(
25
+ "push",
26
+ "Run safety checks and execute git push. Checks forbidden paths, branch ownership, and repository visibility before pushing.",
27
+ {
28
+ force: z
29
+ .boolean()
30
+ .optional()
31
+ .describe(
32
+ "Bypass safety checks (except visibility). Use when a previous push was blocked and you want to override."
33
+ ),
34
+ dryRun: z
35
+ .boolean()
36
+ .optional()
37
+ .describe("Show what would be pushed without actually pushing."),
38
+ args: z
39
+ .array(z.string())
40
+ .optional()
41
+ .describe(
42
+ "Additional git push arguments (e.g. remote name, refspec, flags like --no-verify)."
43
+ ),
44
+ },
45
+ async ({ force, dryRun, args }) => {
46
+ try {
47
+ // SAFE_PUSH_GIT_ROOT が設定されている場合は chdir
48
+ const gitRoot = process.env.SAFE_PUSH_GIT_ROOT;
49
+ if (gitRoot) {
50
+ process.chdir(path.resolve(gitRoot));
51
+ }
52
+
53
+ // Git リポジトリ内か確認
54
+ if (!(await isGitRepository())) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text" as const,
59
+ text: "Error: Not a git repository",
60
+ },
61
+ ],
62
+ isError: true,
63
+ };
64
+ }
65
+
66
+ // コミットが存在するか確認
67
+ if (!(await hasCommits())) {
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text" as const,
72
+ text: "Error: No commits found",
73
+ },
74
+ ],
75
+ isError: true,
76
+ };
77
+ }
78
+
79
+ const config = loadConfig();
80
+ const gitArgs = args ?? [];
81
+
82
+ // visibility チェック(force でもバイパス不可)
83
+ if (config.allowedVisibility && config.allowedVisibility.length > 0) {
84
+ try {
85
+ const visibilityResult = await checkVisibility(
86
+ config.allowedVisibility
87
+ );
88
+ if (visibilityResult && !visibilityResult.allowed) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text" as const,
93
+ text: `Blocked: ${visibilityResult.reason}`,
94
+ },
95
+ ],
96
+ isError: true,
97
+ };
98
+ }
99
+ } catch (error) {
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text" as const,
104
+ text: `Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n${error instanceof Error ? error.message : String(error)}`,
105
+ },
106
+ ],
107
+ isError: true,
108
+ };
109
+ }
110
+ }
111
+
112
+ // force の場合はチェックをスキップ
113
+ if (force) {
114
+ if (dryRun) {
115
+ return {
116
+ content: [
117
+ {
118
+ type: "text" as const,
119
+ text: "Dry run: would push (checks bypassed with force)",
120
+ },
121
+ ],
122
+ };
123
+ }
124
+
125
+ const result = await execPush(gitArgs);
126
+ if (result.success) {
127
+ return {
128
+ content: [
129
+ {
130
+ type: "text" as const,
131
+ text: result.output
132
+ ? `Push successful (force)\n${result.output}`
133
+ : "Push successful (force)",
134
+ },
135
+ ],
136
+ };
137
+ }
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text" as const,
142
+ text: `Push failed: ${result.output}`,
143
+ },
144
+ ],
145
+ isError: true,
146
+ };
147
+ }
148
+
149
+ // 通常のチェック
150
+ const checkResult = await checkPush(config);
151
+
152
+ if (!checkResult.allowed) {
153
+ const details = checkResult.details;
154
+ let message = `Push blocked: ${checkResult.reason}\n\nDetails:\n- Branch: ${details.currentBranch}\n- New branch: ${details.isNewBranch}\n- Own last commit: ${details.isOwnLastCommit}\n- Author: ${details.authorEmail}\n- Local: ${details.localEmail}`;
155
+
156
+ if (details.forbiddenFiles.length > 0) {
157
+ message += `\n- Forbidden files: ${details.forbiddenFiles.join(", ")}`;
158
+ }
159
+
160
+ // onForbidden: "prompt" の場合、MCP ではインタラクティブ確認不可なので force を案内
161
+ if (
162
+ config.onForbidden === "prompt" &&
163
+ details.hasForbiddenChanges
164
+ ) {
165
+ message +=
166
+ '\n\nThis repository is configured with onForbidden: "prompt". Since interactive confirmation is not available via MCP, you can re-run with force: true to bypass this check.';
167
+ }
168
+
169
+ return {
170
+ content: [
171
+ {
172
+ type: "text" as const,
173
+ text: message,
174
+ },
175
+ ],
176
+ isError: true,
177
+ };
178
+ }
179
+
180
+ // チェック通過
181
+ if (dryRun) {
182
+ const details = checkResult.details;
183
+ return {
184
+ content: [
185
+ {
186
+ type: "text" as const,
187
+ text: `Dry run: would push\nReason: ${checkResult.reason}\nBranch: ${details.currentBranch}`,
188
+ },
189
+ ],
190
+ };
191
+ }
192
+
193
+ // push 実行
194
+ const result = await execPush(gitArgs);
195
+ if (result.success) {
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text" as const,
200
+ text: result.output
201
+ ? `Push successful\n${result.output}`
202
+ : "Push successful",
203
+ },
204
+ ],
205
+ };
206
+ }
207
+ return {
208
+ content: [
209
+ {
210
+ type: "text" as const,
211
+ text: `Push failed: ${result.output}`,
212
+ },
213
+ ],
214
+ isError: true,
215
+ };
216
+ } catch (error) {
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text" as const,
221
+ text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
222
+ },
223
+ ],
224
+ isError: true,
225
+ };
226
+ }
227
+ }
228
+ );
229
+
230
+ return server;
231
+ }
232
+
233
+ async function startStdioServer(): Promise<void> {
234
+ const server = createMcpServer();
235
+ const transport = new StdioServerTransport();
236
+ await server.connect(transport);
237
+ console.error("safe-push MCP server started (stdio)");
238
+ await new Promise<void>((resolve) => {
239
+ transport.onclose = () => resolve();
240
+ });
241
+ }
242
+
243
+ async function startHttpServer(port: number): Promise<void> {
244
+ const transports = new Map<string, StreamableHTTPServerTransport>();
245
+
246
+ function readBody(req: IncomingMessage): Promise<string> {
247
+ return new Promise((resolve, reject) => {
248
+ let data = "";
249
+ req.on("data", (chunk: Buffer) => {
250
+ data += chunk.toString();
251
+ });
252
+ req.on("end", () => resolve(data));
253
+ req.on("error", reject);
254
+ });
255
+ }
256
+
257
+ const httpServer = createServer(
258
+ async (req: IncomingMessage, res: ServerResponse) => {
259
+ const url = new URL(
260
+ req.url ?? "/",
261
+ `http://${req.headers.host ?? "localhost"}`
262
+ );
263
+
264
+ if (url.pathname !== "/mcp") {
265
+ res.writeHead(404, { "Content-Type": "application/json" });
266
+ res.end(JSON.stringify({ error: "Not Found" }));
267
+ return;
268
+ }
269
+
270
+ const method = req.method?.toUpperCase();
271
+
272
+ if (method === "POST") {
273
+ try {
274
+ const body = await readBody(req);
275
+ const parsedBody = JSON.parse(body);
276
+ const sessionId = req.headers["mcp-session-id"] as
277
+ | string
278
+ | undefined;
279
+
280
+ let transport: StreamableHTTPServerTransport;
281
+
282
+ if (sessionId && transports.has(sessionId)) {
283
+ transport = transports.get(sessionId)!;
284
+ } else if (!sessionId && isInitializeRequest(parsedBody)) {
285
+ transport = new StreamableHTTPServerTransport({
286
+ sessionIdGenerator: () => randomUUID(),
287
+ onsessioninitialized: (sid: string) => {
288
+ transports.set(sid, transport);
289
+ console.error(`Session initialized: ${sid}`);
290
+ },
291
+ });
292
+
293
+ transport.onclose = () => {
294
+ const sid = transport.sessionId;
295
+ if (sid) {
296
+ transports.delete(sid);
297
+ console.error(`Session closed: ${sid}`);
298
+ }
299
+ };
300
+
301
+ const server = createMcpServer();
302
+ await server.connect(transport);
303
+ await transport.handleRequest(req, res, parsedBody);
304
+ return;
305
+ } else {
306
+ res.writeHead(400, { "Content-Type": "application/json" });
307
+ res.end(
308
+ JSON.stringify({
309
+ jsonrpc: "2.0",
310
+ error: {
311
+ code: -32000,
312
+ message: "Bad Request: No valid session ID provided",
313
+ },
314
+ id: null,
315
+ })
316
+ );
317
+ return;
318
+ }
319
+
320
+ await transport.handleRequest(req, res, parsedBody);
321
+ } catch {
322
+ if (!res.headersSent) {
323
+ res.writeHead(500, { "Content-Type": "application/json" });
324
+ res.end(
325
+ JSON.stringify({
326
+ jsonrpc: "2.0",
327
+ error: { code: -32603, message: "Internal server error" },
328
+ id: null,
329
+ })
330
+ );
331
+ }
332
+ }
333
+ } else if (method === "GET") {
334
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
335
+ if (!sessionId || !transports.has(sessionId)) {
336
+ res.writeHead(400, { "Content-Type": "text/plain" });
337
+ res.end("Invalid or missing session ID");
338
+ return;
339
+ }
340
+ const transport = transports.get(sessionId)!;
341
+ await transport.handleRequest(req, res);
342
+ } else if (method === "DELETE") {
343
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
344
+ if (!sessionId || !transports.has(sessionId)) {
345
+ res.writeHead(400, { "Content-Type": "text/plain" });
346
+ res.end("Invalid or missing session ID");
347
+ return;
348
+ }
349
+ const transport = transports.get(sessionId)!;
350
+ await transport.handleRequest(req, res);
351
+ } else {
352
+ res.writeHead(405, { "Content-Type": "text/plain" });
353
+ res.end("Method Not Allowed");
354
+ }
355
+ }
356
+ );
357
+
358
+ await new Promise<void>((resolve) => {
359
+ httpServer.listen(port, () => {
360
+ console.error(`safe-push MCP server started (HTTP) on port ${port}`);
361
+ });
362
+ httpServer.on("close", resolve);
363
+ });
364
+ }
365
+
366
+ export function createMcpCommand(): Command {
367
+ const cmd = new Command("mcp")
368
+ .description("Start MCP server for Claude Code integration")
369
+ .option("--http", "Start as HTTP server instead of stdio")
370
+ .option("--port <number>", "HTTP server port (default: PORT env or 3000)")
371
+ .action(async (opts: { http?: boolean; port?: string }) => {
372
+ if (opts.http) {
373
+ const port = opts.port
374
+ ? Number(opts.port)
375
+ : Number(process.env.PORT) || 3000;
376
+ await startHttpServer(port);
377
+ } else {
378
+ await startStdioServer();
379
+ }
380
+ });
381
+
382
+ return cmd;
383
+ }