Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
git.ts14.0 KB · 411 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
/**
 * Git Smart HTTP routes.
 *
 * Mounted at /:owner/:repo.git/ — handles clone, fetch, push.
 */

import { Hono } from "hono";
import { and, eq } from "drizzle-orm";
import { db } from "../db";
import { repositories, users } from "../db/schema";
import { getInfoRefs, serviceRpc } from "../git/protocol";
import { repoExists, getRepoPath } from "../git/repository";
import { join } from "path";
import { onPostReceive } from "../hooks/post-receive";
import { invalidateRepoCache } from "../lib/cache";
import { trackByName } from "../lib/traffic";
import {
  evaluatePushPolicy,
  formatPolicyError,
  installPackInspectionHookForRepo,
} from "../lib/push-policy";
import { resolvePusher } from "../lib/git-push-auth";
import {
  resolveRepoAccess,
  satisfiesAccess,
  type RepoAccessLevel,
} from "../middleware/repo-access";
import { audit } from "../lib/notify";

const git = new Hono();

/** Extract repo name from the ":repo.git" param Hono generates. */
function gitParams(c: any): { owner: string; repo: string } {
  const params = c.req.param();
  const owner: string = params.owner;
  const raw: string = params["repo.git"] ?? params.repo ?? "";
  const repo = raw.replace(/\.git$/, "");
  return { owner, repo };
}

// Discovery: GET /:owner/:repo.git/info/refs?service=...
git.get("/:owner/:repo.git/info/refs", async (c) => {
  // gitParams strips the ".git" suffix regardless of how the Hono version in
  // play names the ":repo.git" param. Everything downstream (repoExists,
  // getRepoPath, getInfoRefs) expects the bare repo NAME — passing the raw
  // "name.git" segment resolves to "<name>.git.git" on disk and 404s every
  // clone/push (the regression that broke Smart HTTP after the Hono bump).
  const { owner, repo } = gitParams(c);
  const service = c.req.query("service");

  if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
    return c.text("Invalid service", 400);
  }

  if (!(await repoExists(owner, repo))) {
    return c.text("Repository not found", 404);
  }

  // C2/C3 — authorize before disclosing refs. upload-pack (clone/fetch)
  // needs read; receive-pack (push) needs write. Anonymous read of a public
  // repo passes (resolveRepoAccess → "read"); private repos require auth.
  const required: RepoAccessLevel =
    service === "git-receive-pack" ? "write" : "read";
  // info/refs is the discovery step: on insufficient-with-auth we return 404
  // for both services (privacy-preserving — don't confirm the repo exists to
  // a non-collaborator). The actual receive-pack POST returns a 403 instead.
  const denied = await gitAccessGate(c, owner, repo, required, "notfound");
  if (denied) return denied;

  return getInfoRefs(owner, repo, service);
});

// GET /:owner/:repo.git/HEAD
git.get("/:owner/:repo.git/HEAD", async (c) => {
  const { owner, repo } = gitParams(c);
  if (!(await repoExists(owner, repo))) {
    return c.text("Repository not found", 404);
  }
  // C2 — same read gate as upload-pack.
  const denied = await gitAccessGate(c, owner, repo, "read", "notfound");
  if (denied) return denied;
  // Resolve through getRepoPath — the old hardcoded relative "repos/…" path
  // silently missed installs where GIT_REPOS_PATH is absolute (e.g. /data/repos).
  const file = Bun.file(join(getRepoPath(owner, repo), "HEAD"));
  if (!(await file.exists())) return c.text("Not found", 404);
  return c.text(await file.text());
});

// Upload pack (clone/fetch)
git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
  const { owner, repo } = gitParams(c);
  if (!(await repoExists(owner, repo))) {
    return c.text("Repository not found", 404);
  }
  // C2 — require read access before serving objects. Anonymous read of a
  // public repo passes; private repos require valid credentials.
  const denied = await gitAccessGate(c, owner, repo, "read", "notfound");
  if (denied) return denied;
  // F1 — fire-and-forget clone tracking. Log on failure so traffic-stats
  // gaps are diagnosable (was a silent .catch(() => {}).)
  trackByName(owner, repo, "clone", {
    ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
    userAgent: c.req.header("user-agent") || null,
  }).catch((err) => {
    console.warn(
      `[git] clone tracking failed for ${owner}/${repo}:`,
      err instanceof Error ? err.message : err
    );
  });
  return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
});

// Receive pack (push)
git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
  const { owner, repo } = gitParams(c);
  if (!(await repoExists(owner, repo))) {
    return c.text("Repository not found", 404);
  }

  // Read the body once; we parse refs from it for both pre-receive policy
  // checks and the existing post-receive hook. Large pushes arrive gzip'd
  // (same threshold behavior as fetch/clone negotiation — see protocol.ts)
  // — decompress before ref-parsing or a big push silently skips policy
  // checks and the post-receive hook (refs parse as empty, not an error).
  const rawBodyBuffer = await c.req.arrayBuffer();
  const rawBodyBytes = new Uint8Array(rawBodyBuffer);
  let bodyBuffer: ArrayBuffer = rawBodyBuffer;
  if (rawBodyBytes.length >= 2 && rawBodyBytes[0] === 0x1f && rawBodyBytes[1] === 0x8b) {
    const decompressed = Bun.gunzipSync(rawBodyBytes);
    bodyBuffer = decompressed.buffer.slice(
      decompressed.byteOffset,
      decompressed.byteOffset + decompressed.byteLength
    ) as ArrayBuffer;
  }
  const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));

  // Resolve the pusher early so we can pass it to both the policy gate and
  // the post-receive hook (e.g. for AI auto-issue attribution).
  const authHeader = c.req.header("authorization");
  let pusherUserId = "";
  try {
    const pusher = await resolvePusher(authHeader);
    pusherUserId = pusher?.userId || "";
  } catch {
    // Fail open — policy gate and post-receive handle missing pushers fine.
  }

  // C3 — REQUIRE write access before running the service. Anonymous pushes
  // and read-only collaborators are rejected here, before serviceRpc. This
  // runs BEFORE the push-policy / pack-inspection / post-receive logic so
  // those only ever execute for authorized pushers.
  {
    const authRow = await loadRepoRow(owner, repo);
    if (!authRow) {
      return c.text("Repository not found", 404);
    }
    const access = await resolveRepoAccess({
      repoId: authRow.id,
      userId: pusherUserId || null,
      isPublic: !authRow.isPrivate,
    });
    if (!satisfiesAccess(access, "write")) {
      if (!authHeader) {
        return c.text("Authentication required", 401, {
          "WWW-Authenticate": 'Basic realm="Gluecron"',
        });
      }
      return c.text(
        "You do not have write access to this repository.",
        403
      );
    }
  }

  // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
  // on any DB hiccup (the helper returns {allowed:true} in that case).
  // We also retain the repoRow so the pack-inspection hook below can reuse it.
  let cachedRepoId: string | null = null;
  if (refs.length > 0) {
    try {
      const repoRow = await loadRepoRow(owner, repo);
      if (repoRow) {
        cachedRepoId = repoRow.id;
        const decision = await evaluatePushPolicy({
          repositoryId: repoRow.id,
          refs,
          pusherUserId: pusherUserId || null,
        });
        if (!decision.allowed) {
          // Audit the rejection so owners can see blocked-push attempts
          // even though the request never reached the post-receive hook.
          // Fire-and-forget — never block the 403.
          audit({
            userId: pusherUserId || null,
            repositoryId: repoRow.id,
            action: "push.rejected",
            targetType: "repository",
            targetId: repoRow.id,
            ip:
              c.req.header("x-forwarded-for") ||
              c.req.header("x-real-ip") ||
              undefined,
            userAgent: c.req.header("user-agent") || undefined,
            metadata: {
              violations: decision.violations,
              refs: refs.map((r) => r.refName),
              pusherSource: pusherUserId ? "user" : "anonymous",
            },
          }).catch((err) => {
            console.warn(
              `[git] push.rejected audit write failed for ${owner}/${repo}:`,
              err instanceof Error ? err.message : err
            );
          });
          // Returning 403 with a plain-text body — git smart-HTTP clients
          // surface the body to the user (`remote: ` prefix). Existing
          // behaviour for repos with no policy is unchanged.
          return c.text(formatPolicyError(decision.violations), 403);
        }
      }
    } catch {
      // Never wedge a legitimate push on enforcer failure.
    }
  }

  // Pack-content inspection: install a pre-receive hook that enforces
  // commit_message_pattern, blocked_file_paths, and max_file_size rules,
  // plus an unconditional critical-secret scan (2026-07-16 — previously
  // secrets were only caught after the fact by the async post-receive scan).
  // git-receive-pack runs the hook before promoting quarantined objects, so
  // a hook exit-1 leaves the repo unchanged.  We clean up the temp dir
  // unconditionally after serviceRpc returns.
  let hookEnv: Record<string, string> | undefined;
  let hookCleanup: (() => Promise<void>) | undefined;
  if (refs.length > 0 && cachedRepoId) {
    try {
      const hook = await installPackInspectionHookForRepo(cachedRepoId);
      if (hook) {
        hookEnv = hook.env;
        hookCleanup = hook.cleanup;
      }
    } catch {
      // fail-open
    }
  }

  let response: Response;
  try {
    response = await serviceRpc(
      owner,
      repo,
      "git-receive-pack",
      bodyBuffer,
      hookEnv
    );
  } finally {
    hookCleanup?.().catch(() => {});
  }

  // Invalidate cached git data for this repo immediately
  invalidateRepoCache(owner, repo);

  // Fire post-receive hooks asynchronously (don't block response).
  if (refs.length > 0) {
    onPostReceive(owner, repo, refs, pusherUserId).catch((err) =>
      console.error("[post-receive] hook error:", err)
    );
  }

  return response;
});

/**
 * Look up the repositories row keyed by owner username + repo name.
 * Pure DB helper kept local to this file because it's only used by the
 * push-policy gate; returns null on miss/error so the caller fails open.
 */
async function loadRepoRow(
  ownerName: string,
  repoName: string
): Promise<{ id: string; isPrivate: boolean; ownerId: string } | null> {
  try {
    const [ownerRow] = await db
      .select({ id: users.id })
      .from(users)
      .where(eq(users.username, ownerName))
      .limit(1);
    if (!ownerRow) return null;
    const [repoRow] = await db
      .select({
        id: repositories.id,
        isPrivate: repositories.isPrivate,
        ownerId: repositories.ownerId,
      })
      .from(repositories)
      .where(
        and(
          eq(repositories.ownerId, ownerRow.id),
          eq(repositories.name, repoName)
        )
      )
      .limit(1);
    return repoRow || null;
  } catch {
    return null;
  }
}

/**
 * Authorization gate for the git Smart HTTP layer (C2/C3 hotfix).
 *
 * Resolves the caller from the Authorization header, computes their effective
 * access level to the repo, and returns a `Response` to short-circuit with if
 * access is insufficient — or `null` to proceed.
 *
 *  - Public repos grant "read" to anonymous callers, so read gates pass with
 *    no auth (anonymous clone of a public repo keeps working).
 *  - Insufficient access with NO auth header → 401 + WWW-Authenticate so the
 *    git CLI prompts for credentials.
 *  - Insufficient access WITH auth → 404 (read gate, privacy-preserving) or
 *    403 (write gate, existence already implied by prior 404-on-miss).
 */
async function gitAccessGate(
  c: any,
  owner: string,
  repo: string,
  required: RepoAccessLevel,
  onInsufficientWithAuth: "notfound" | "forbidden"
): Promise<Response | null> {
  const authHeader = c.req.header("authorization");

  const repoRow = await loadRepoRow(owner, repo);
  // If we can't resolve the row (shouldn't happen after repoExists), fail
  // closed for private-sensitive gates by treating as not found.
  if (!repoRow) {
    return c.text("Repository not found", 404);
  }

  let userId: string | null = null;
  try {
    const pusher = await resolvePusher(authHeader);
    userId = pusher?.userId || null;
  } catch {
    userId = null;
  }

  const access = await resolveRepoAccess({
    repoId: repoRow.id,
    userId,
    isPublic: !repoRow.isPrivate,
  });

  if (satisfiesAccess(access, required)) {
    return null;
  }

  // No credentials supplied — ask git to authenticate.
  if (!authHeader) {
    return c.text("Authentication required", 401, {
      "WWW-Authenticate": 'Basic realm="Gluecron"',
    });
  }

  // Credentials supplied but insufficient.
  if (onInsufficientWithAuth === "forbidden") {
    return c.text("You do not have write access to this repository.", 403);
  }
  return c.text("Repository not found", 404);
}

/**
 * Parse ref updates from git-receive-pack request body.
 * Format: <old-sha> <new-sha> <ref-name>
 */
function parseReceivePackRefs(
  data: Uint8Array
): Array<{ oldSha: string; newSha: string; refName: string }> {
  const text = new TextDecoder().decode(data);
  const refs: Array<{ oldSha: string; newSha: string; refName: string }> = [];
  // Pkt-line format: 4-hex-length followed by data
  let offset = 0;
  while (offset < text.length) {
    const lenHex = text.slice(offset, offset + 4);
    const len = parseInt(lenHex, 16);
    if (len === 0) {
      offset += 4;
      break; // flush packet
    }
    if (len < 4) break;
    const line = text.slice(offset + 4, offset + len);
    offset += len;

    // Match: <old-sha> <new-sha> <ref-name>\0<capabilities>
    // or:    <old-sha> <new-sha> <ref-name>
    const match = line.match(
      /^([0-9a-f]{40}) ([0-9a-f]{40}) ([^\0\n]+)/
    );
    if (match) {
      refs.push({
        oldSha: match[1],
        newSha: match[2],
        refName: match[3].trim(),
      });
    }
  }
  return refs;
}

export const __test = { gitParams, parseReceivePackRefs };

export default git;