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.ts19.6 KB · 473 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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
/**
 * 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.
 *
 * Pack-content inspection also carries an unconditional secret scan (2026-07-16):
 * previously a critical secret (AWS/GitHub/Anthropic/Stripe key, PEM private
 * key) landed in the object store on any direct push and was only caught
 * after the fact by the async post-receive scan in security-scan.ts, which
 * can only react with a follow-up remediation commit — the secret was
 * already pushed. The pre-receive hook now also scans changed-file content
 * and rejects the push before objects are promoted, regardless of whether
 * the repo has any rulesets configured. Kill switch: config.secretScanOnPushDisabled.
 *
 * 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 { config } from "./config";
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[] };

/**
 * High-signal, no-imports secret detectors used only inside the pre-receive
 * hook sandbox (see buildEvalScript() below — that script runs standalone
 * via `bun run` with zero project imports by design, so these patterns are
 * intentionally a trimmed duplicate of security-scan.ts's SECRET_PATTERNS,
 * restricted to the "critical" subset — the same threshold runAllGateChecks
 * uses to hard-block a PR merge (gate.ts: criticalSecrets === 0). Kept in
 * sync by src/__tests__/push-policy-secret-scan.test.ts, which asserts every
 * critical pattern in security-scan.ts has a corresponding regex here.
 */
const HOOK_SECRET_PATTERNS: Array<{ type: string; source: string; flags: string }> = [
  { type: "AWS Access Key", source: "\\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\\b", flags: "" },
  { type: "AWS Secret Key", source: "aws(.{0,20})?(secret|access)?(.{0,20})?['\\\"]([A-Za-z0-9/+=]{40})['\\\"]", flags: "i" },
  { type: "GitHub Token", source: "\\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\\b", flags: "" },
  { type: "Anthropic API Key", source: "\\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\\b", flags: "" },
  { type: "OpenAI API Key", source: "\\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\\b", flags: "" },
  { type: "Stripe Key", source: "\\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\\b", flags: "" },
  { type: "Private Key (PEM)", source: "-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----", flags: "" },
];

/** Exported for the sync test — the trimmed duplicate list above. */
export function hookSecretPatternTypes(): string[] {
  return HOOK_SECRET_PATTERNS.map((p) => p.type);
}

const HOOK_SKIP_PATH_RE =
  "(^|/)(\\.git|node_modules|vendor|dist|build|\\.next|\\.cache)/|\\.(png|jpe?g|gif|webp|ico|svg|pdf|mp4|mov|wasm|woff2?|ttf|eot|map)$|(^|/)(bun\\.lockb?|package-lock\\.json|yarn\\.lock|pnpm-lock\\.yaml)$";
const HOOK_PLACEHOLDER_RE = "example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme";

/**
 * JS source for the per-push evaluator that the pre-receive hook calls once
 * per ref.  Receives four file-path arguments:
 *   argv[2] = rules.json path
 *   argv[3] = commits file  (lines: "<sha> <subject>")
 *   argv[4] = sizes file    (lines: "<bytes> <path>")
 *   argv[5] = contents file (lines: "<path>\t<base64 content>", size-capped)
 *
 * 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');",
    "  }",
    "}",
    // Unconditional secret scan — independent of rules.json / rulesets.
    // Only runs when argv[5] (contents file) was provided by the caller.
    `const SECRET_PATTERNS = ${JSON.stringify(HOOK_SECRET_PATTERNS)}.map(p => ({ type: p.type, re: new RegExp(p.source, p.flags) }));`,
    `const SKIP_RE = new RegExp(${JSON.stringify(HOOK_SKIP_PATH_RE)}, 'i');`,
    `const PLACEHOLDER_RE = new RegExp(${JSON.stringify(HOOK_PLACEHOLDER_RE)}, 'i');`,
    "if (process.argv[5]) {",
    "  let contentLines = [];",
    "  try { contentLines = readFileSync(process.argv[5], 'utf8').split('\\n').filter(Boolean); } catch { contentLines = []; }",
    "  for (const line of contentLines) {",
    "    const tab = line.indexOf('\\t');",
    "    if (tab < 0) continue;",
    "    const fp = line.slice(0, tab);",
    "    const b64 = line.slice(tab + 1);",
    "    if (SKIP_RE.test(fp)) continue;",
    "    let text;",
    "    try { text = Buffer.from(b64, 'base64').toString('utf8'); } catch { continue; }",
    "    const fileLines = text.split('\\n');",
    "    for (let i = 0; i < fileLines.length; i++) {",
    "      const l = fileLines[i];",
    "      if (PLACEHOLDER_RE.test(l)) continue;",
    "      for (const p of SECRET_PATTERNS) {",
    "        if (p.re.test(l)) {",
    "          out.push('active' + SEP + 'Secret scan: possible ' + p.type + ' in ' + fp + ':' + (i + 1) + ' — push rejected. Remove the secret and use environment variables or a secrets manager, then push again.');",
    "          break;",
    "        }",
    "      }",
    "    }",
    "  }",
    "}",
    "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)`,
    `  CONTENTS_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. Also dump
    // base64 content (capped at 256KB/file) for the secret scan below —
    // a side write to CONTENTS_TMP, independent of this loop's own
    // stdout redirect into SIZES_TMP.
    `  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"`,
    `    if [ -n "${D}BLOB" ] && [ "${D}SZ" -gt 0 ] && [ "${D}SZ" -le 262144 ]; then`,
    `      B64=$(git cat-file -p "${D}BLOB" 2>/dev/null | base64 -w0 2>/dev/null || git cat-file -p "${D}BLOB" 2>/dev/null | base64 | tr -d '\\n')`,
    `      [ -n "${D}B64" ] && printf '%s\\t%s\\n' "${D}FP" "${D}B64" >> "${D}CONTENTS_TMP"`,
    `    fi`,
    `  done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
    "",
    `  RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_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" "${D}CONTENTS_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[],
  opts: { secretScan?: boolean } = {}
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
  const secretScan = opts.secretScan !== false;

  // 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 AND secret scan disabled → skip hook entirely.
  if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) {
    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 {
    rulesets = [];
  }
  // Always install — even with zero rulesets — so the unconditional secret
  // scan still runs. Only a listRulesetsForRepo throw plus secret-scan being
  // disabled entirely skips the hook (handled by installPackInspectionHook's
  // own early-return when both inputs are empty).
  return installPackInspectionHook(rulesets, {
    secretScan: !config.secretScanOnPushDisabled,
  });
}

/** 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`;
}