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
push-policy.ts14.5 KB · 388 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
/**
 * Push-policy enforcement — runs at the HTTP layer before git-receive-pack
 * actually accepts the pack.
 *
 * Until now Gluecron's protected-tag and ruleset surfaces were advisory:
 * `src/hooks/post-receive.ts` would log audit entries after the push had
 * already landed. This module flips them to truly blocking by evaluating
 * a list of refs at receive time and returning {allowed:false, violations}
 * so the route can short-circuit with a 403.
 *
 * Refs evaluable from name+sha alone (no pack inspection):
 *   - protected_tags                  — tag pushes must come from owner/bypass
 *   - ruleset.tag_name_pattern        — disallow tag names matching pattern
 *   - ruleset.branch_name_pattern     — disallow branch names matching pattern
 *   - ruleset.forbid_force_push       — heuristic: detected when oldSha was
 *                                       not the zero-SHA and newSha differs.
 *                                       True force-push detection requires a
 *                                       reachability check we don't run here;
 *                                       the existing `forcePush` boolean on
 *                                       PushContext is wired to false unless
 *                                       a smarter caller fills it in.
 *
 * Pure helpers + DB callers; never throws into the request path. On any
 * unexpected failure we return {allowed:true} (fail-open) to preserve the
 * existing no-policy behaviour rather than wedging legitimate pushes when
 * Postgres hiccups.
 */

import { mkdtemp, writeFile, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import {
  matchProtectedTag,
  canBypassProtectedTag,
} from "./protected-tags";
import {
  listRulesetsForRepo,
  evaluatePush,
  parseParams,
  type PushContext,
} from "./rulesets";
import type { RulesetRule, RepoRuleset } from "../db/schema";

export type RefUpdate = {
  oldSha: string;
  newSha: string;
  refName: string;
};

export type PushPolicyArgs = {
  repositoryId: string;
  refs: RefUpdate[];
  pusherUserId: string | null;
  /** Absolute path to the bare repo dir; enables pack-content inspection. */
  repoPath?: string;
};

export type PushPolicyResult = {
  allowed: boolean;
  violations: string[];
};

/** "0000000000000000000000000000000000000000" — 40 zeros. */
export const ZERO_SHA = "0".repeat(40);

// ----------------------------------------------------------------------------
// Pack-content inspection via pre-receive hook
// ----------------------------------------------------------------------------

type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };

/**
 * JS source for the per-push evaluator that the pre-receive hook calls once
 * per ref.  Receives three file-path arguments:
 *   argv[1] = rules.json path
 *   argv[2] = commits file  (lines: "<sha> <subject>")
 *   argv[3] = sizes file    (lines: "<bytes> <path>")
 *
 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
 * Exits 0 always — the shell hook interprets the output.
 */
function buildEvalScript(): string {
  // Written as a plain JS string so no TypeScript template interpolation occurs.
  // Uses only Node/Bun built-ins; no imports from the Gluecron codebase.
  return [
    "import { readFileSync } from 'fs';",
    "const rules   = JSON.parse(readFileSync(process.argv[2], 'utf8'));",
    "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);",
    "const sizes   = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);",
    "const SEP = '\\t';",
    "const out = [];",
    "for (const line of commits) {",
    "  const sp = line.indexOf(' ');",
    "  if (sp < 0) continue;",
    "  const sha = line.slice(0, sp);",
    "  const msg = line.slice(sp + 1);",
    "  for (const r of rules.commitMsgRules) {",
    "    const pattern = String(r.params.pattern || '');",
    "    if (!pattern) continue;",
    "    let re;",
    "    try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }",
    "    const req = r.params.require !== false;",
    "    const ok  = re.test(msg);",
    "    if (req && !ok)  out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');",
    "    if (!req && ok)  out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');",
    "  }",
    "}",
    "for (const line of sizes) {",
    "  const sp = line.indexOf(' ');",
    "  if (sp < 0) continue;",
    "  const sz = Number(line.slice(0, sp));",
    "  const fp = line.slice(sp + 1);",
    "  for (const r of rules.blockedPathRules) {",
    "    const globs = Array.isArray(r.params.paths) ? r.params.paths : [];",
    "    for (const g of globs) {",
    "      const parts = [];",
    "      let i = 0;",
    "      while (i < g.length) {",
    "        const ch = g[i];",
    "        if (ch === '*') {",
    "          if (g[i+1] === '*') { parts.push('.*'); i += 2; }",
    "          else { parts.push('[^/]*'); i++; }",
    "        } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {",
    "          parts.push('\\\\' + ch); i++;",
    "        } else { parts.push(ch); i++; }",
    "      }",
    "      const re = new RegExp('^' + parts.join('') + '$');",
    "      if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');",
    "    }",
    "  }",
    "  for (const r of rules.maxSizeRules) {",
    "    const limit = Number(r.params.bytes || 0);",
    "    if (limit && sz > limit)",
    "      out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');",
    "  }",
    "}",
    "process.stdout.write(out.join('\\n'));",
  ].join("\n") + "\n";
}

/**
 * Generate a bash pre-receive hook that calls the companion eval.js once per
 * pushed ref.  Both file paths are embedded literals so the script is
 * fully self-contained.
 */
function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string {
  const D = "$";
  return [
    "#!/bin/bash",
    "set -uo pipefail",
    `EVAL_SCRIPT='${evalScriptPath}'`,
    `RULES_JSON='${rulesJsonPath}'`,
    "FAILED=0",
    "",
    `while IFS=' ' read -r OLD NEW REF; do`,
    `  [[ "${D}NEW" =~ ^0+${D} ]] && continue`,
    `  if [[ "${D}OLD" =~ ^0+${D} ]]; then`,
    `    LOG_RANGE="${D}NEW"`,
    // Empty-tree SHA — safe diff base for new branches with no parent.
    `    DIFF_BASE=4b825dc642cb6eb9a060e54bf8d69288fbee4904`,
    `  else`,
    `    LOG_RANGE="${D}OLD..${D}NEW"`,
    `    DIFF_BASE="${D}OLD"`,
    `  fi`,
    "",
    `  COMMITS_TMP=$(mktemp)`,
    `  SIZES_TMP=$(mktemp)`,
    `  PATHS_TMP=$(mktemp)`,
    `  git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`,
    `  git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
    "",
    // Build sizes file: "<bytes> <path>" per changed file.
    `  while IFS= read -r FP; do`,
    `    [ -z "${D}FP" ] && continue`,
    `    BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
    `    SZ=0`,
    `    [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
    `    printf '%s %s\\n' "${D}SZ" "${D}FP"`,
    `  done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
    "",
    `  RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" 2>/dev/null || true)`,
    "",
    // Each output line is "<enforcement>\t<message>"; split on tab.
    `  while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
    `    [ -z "${D}MSG_OUT" ] && continue`,
    `    echo "remote: ${D}MSG_OUT" >&2`,
    `    [ "${D}ENFORCE" = "active" ] && FAILED=1`,
    `  done <<< "${D}RESULT"`,
    "",
    `  rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP"`,
    `done`,
    "",
    `exit ${D}FAILED`,
  ].join("\n") + "\n";
}

/**
 * Write a pre-receive hook + companion rules JSON to a temp directory and
 * return the git env vars that redirect git to use that hooks dir, plus a
 * cleanup function.
 *
 * Callers must always invoke cleanup() — even on error — to remove the temp
 * directory.  Failure to set up the hook dir is non-fatal: we return null so
 * the caller can proceed without pack-content inspection rather than wedging
 * the push.
 */
export async function installPackInspectionHook(
  rulesets: ActiveRuleset[]
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
  // Collect pack-content rules from non-disabled rulesets.
  type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
  const commitMsgRules: RuleEntry[] = [];
  const blockedPathRules: RuleEntry[] = [];
  const maxSizeRules: RuleEntry[] = [];

  for (const rs of rulesets) {
    if (rs.enforcement === "disabled") continue;
    for (const r of rs.rules) {
      const p = parseParams(r.params);
      const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p };
      if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry);
      else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry);
      else if (r.ruleType === "max_file_size") maxSizeRules.push(entry);
    }
  }

  // No pack-content rules → skip hook installation entirely.
  if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length) {
    return null;
  }

  try {
    const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-"));
    const rulesJsonPath = join(dir, "rules.json");
    await writeFile(
      rulesJsonPath,
      JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }),
      { mode: 0o644 }
    );
    const evalScriptPath = join(dir, "eval.js");
    await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 });
    const hookPath = join(dir, "pre-receive");
    await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 });
    return {
      env: {
        GIT_CONFIG_COUNT: "1",
        GIT_CONFIG_KEY_0: "core.hooksPath",
        GIT_CONFIG_VALUE_0: dir,
      },
      cleanup: async () => {
        try {
          await rm(dir, { recursive: true, force: true });
        } catch {
          // best-effort
        }
      },
    };
  } catch {
    return null;
  }
}

const ALLOW: PushPolicyResult = { allowed: true, violations: [] };

/**
 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
 * as a branch since the evaluator's tag-only rules will gracefully no-op.
 */
function refType(refName: string): "branch" | "tag" {
  return refName.startsWith("refs/tags/") ? "tag" : "branch";
}

/**
 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
 * and return the aggregated decision. Multiple violations across refs are
 * concatenated so the user sees every problem in one push attempt rather
 * than one-at-a-time.
 */
export async function evaluatePushPolicy(
  args: PushPolicyArgs
): Promise<PushPolicyResult> {
  const { repositoryId, refs, pusherUserId } = args;
  if (!repositoryId || !refs || refs.length === 0) return ALLOW;

  const violations: string[] = [];

  // Protected tags — runs once per ref, only fires for tag refs.
  for (const ref of refs) {
    if (!ref.refName.startsWith("refs/tags/")) continue;
    const tagName = ref.refName.slice("refs/tags/".length);
    let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
    try {
      protectedRule = await matchProtectedTag(repositoryId, tagName);
    } catch {
      protectedRule = null;
    }
    if (!protectedRule) continue;

    // Anonymous pusher → never bypasses. Authenticated pusher must be the
    // owner (or future tag-admin) for this repo.
    let canBypass = false;
    try {
      canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
    } catch {
      canBypass = false;
    }
    if (canBypass) continue;

    const action =
      ref.newSha === ZERO_SHA
        ? "delete"
        : ref.oldSha === ZERO_SHA
        ? "create"
        : "update";
    violations.push(
      `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
    );
  }

  // Rulesets — single DB call, evaluator runs purely on names.
  let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
  try {
    rulesets = await listRulesetsForRepo(repositoryId);
  } catch {
    rulesets = [];
  }

  if (rulesets && rulesets.length > 0) {
    for (const ref of refs) {
      const ctx: PushContext = {
        kind: "push",
        refType: refType(ref.refName),
        refName: ref.refName,
        commits: [],
        // forcePush is left false — true detection requires a reachability
        // check on the new commit, which is in the pack we haven't unpacked.
        forcePush: false,
      };
      let result;
      try {
        result = evaluatePush(rulesets, ctx);
      } catch {
        result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
      }
      if (!result.allowed && result.violations.length > 0) {
        for (const v of result.violations) {
          // Only "active" enforcement blocks; "evaluate" is dry-run.
          if (v.enforcement !== "active") continue;
          violations.push(
            `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
          );
        }
      }
    }
  }

  return violations.length === 0
    ? ALLOW
    : { allowed: false, violations };
}

/**
 * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and
 * call `installPackInspectionHook`.  Returns null on DB error or when there
 * are no pack-content rules to enforce.  Never throws.
 */
export async function installPackInspectionHookForRepo(
  repositoryId: string
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
  let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
  try {
    rulesets = await listRulesetsForRepo(repositoryId);
  } catch {
    return null;
  }
  if (!rulesets.length) return null;
  return installPackInspectionHook(rulesets);
}

/** Build a human-readable error body for the 403 response. */
export function formatPolicyError(violations: string[]): string {
  if (!violations || violations.length === 0) {
    return "Push rejected by Gluecron policy.";
  }
  const lines = violations.map((v) => ` - ${v}`);
  return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
}