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
Blame · Line-by-line history

push-policy.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

push-policy.tsBlame388 lines · 1 contributor
5dc83edClaude1/**
2 * Push-policy enforcement — runs at the HTTP layer before git-receive-pack
3 * actually accepts the pack.
4 *
5 * Until now Gluecron's protected-tag and ruleset surfaces were advisory:
6 * `src/hooks/post-receive.ts` would log audit entries after the push had
7 * already landed. This module flips them to truly blocking by evaluating
8 * a list of refs at receive time and returning {allowed:false, violations}
9 * so the route can short-circuit with a 403.
10 *
11 * Refs evaluable from name+sha alone (no pack inspection):
12 * - protected_tags — tag pushes must come from owner/bypass
13 * - ruleset.tag_name_pattern — disallow tag names matching pattern
14 * - ruleset.branch_name_pattern — disallow branch names matching pattern
15 * - ruleset.forbid_force_push — heuristic: detected when oldSha was
16 * not the zero-SHA and newSha differs.
17 * True force-push detection requires a
18 * reachability check we don't run here;
19 * the existing `forcePush` boolean on
20 * PushContext is wired to false unless
21 * a smarter caller fills it in.
22 *
23 * Pure helpers + DB callers; never throws into the request path. On any
24 * unexpected failure we return {allowed:true} (fail-open) to preserve the
25 * existing no-policy behaviour rather than wedging legitimate pushes when
26 * Postgres hiccups.
27 */
28
ec39211Claude29import { mkdtemp, writeFile, rm } from "fs/promises";
30import { join } from "path";
31import { tmpdir } from "os";
5dc83edClaude32import {
33 matchProtectedTag,
34 canBypassProtectedTag,
35} from "./protected-tags";
36import {
37 listRulesetsForRepo,
38 evaluatePush,
ec39211Claude39 parseParams,
5dc83edClaude40 type PushContext,
41} from "./rulesets";
ec39211Claude42import type { RulesetRule, RepoRuleset } from "../db/schema";
5dc83edClaude43
44export type RefUpdate = {
45 oldSha: string;
46 newSha: string;
47 refName: string;
48};
49
50export type PushPolicyArgs = {
51 repositoryId: string;
52 refs: RefUpdate[];
53 pusherUserId: string | null;
ec39211Claude54 /** Absolute path to the bare repo dir; enables pack-content inspection. */
55 repoPath?: string;
5dc83edClaude56};
57
58export type PushPolicyResult = {
59 allowed: boolean;
60 violations: string[];
61};
62
63/** "0000000000000000000000000000000000000000" — 40 zeros. */
64export const ZERO_SHA = "0".repeat(40);
65
ec39211Claude66// ----------------------------------------------------------------------------
67// Pack-content inspection via pre-receive hook
68// ----------------------------------------------------------------------------
69
70type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };
71
72/**
73 * JS source for the per-push evaluator that the pre-receive hook calls once
74 * per ref. Receives three file-path arguments:
75 * argv[1] = rules.json path
76 * argv[2] = commits file (lines: "<sha> <subject>")
77 * argv[3] = sizes file (lines: "<bytes> <path>")
78 *
79 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
80 * Exits 0 always — the shell hook interprets the output.
81 */
82function buildEvalScript(): string {
83 // Written as a plain JS string so no TypeScript template interpolation occurs.
84 // Uses only Node/Bun built-ins; no imports from the Gluecron codebase.
85 return [
86 "import { readFileSync } from 'fs';",
87 "const rules = JSON.parse(readFileSync(process.argv[2], 'utf8'));",
88 "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);",
89 "const sizes = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);",
90 "const SEP = '\\t';",
91 "const out = [];",
92 "for (const line of commits) {",
93 " const sp = line.indexOf(' ');",
94 " if (sp < 0) continue;",
95 " const sha = line.slice(0, sp);",
96 " const msg = line.slice(sp + 1);",
97 " for (const r of rules.commitMsgRules) {",
98 " const pattern = String(r.params.pattern || '');",
99 " if (!pattern) continue;",
100 " let re;",
101 " try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }",
102 " const req = r.params.require !== false;",
103 " const ok = re.test(msg);",
104 " if (req && !ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');",
105 " if (!req && ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');",
106 " }",
107 "}",
108 "for (const line of sizes) {",
109 " const sp = line.indexOf(' ');",
110 " if (sp < 0) continue;",
111 " const sz = Number(line.slice(0, sp));",
112 " const fp = line.slice(sp + 1);",
113 " for (const r of rules.blockedPathRules) {",
114 " const globs = Array.isArray(r.params.paths) ? r.params.paths : [];",
115 " for (const g of globs) {",
116 " const parts = [];",
117 " let i = 0;",
118 " while (i < g.length) {",
119 " const ch = g[i];",
120 " if (ch === '*') {",
121 " if (g[i+1] === '*') { parts.push('.*'); i += 2; }",
122 " else { parts.push('[^/]*'); i++; }",
123 " } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {",
124 " parts.push('\\\\' + ch); i++;",
125 " } else { parts.push(ch); i++; }",
126 " }",
127 " const re = new RegExp('^' + parts.join('') + '$');",
128 " if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');",
129 " }",
130 " }",
131 " for (const r of rules.maxSizeRules) {",
132 " const limit = Number(r.params.bytes || 0);",
133 " if (limit && sz > limit)",
134 " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');",
135 " }",
136 "}",
137 "process.stdout.write(out.join('\\n'));",
138 ].join("\n") + "\n";
139}
140
141/**
142 * Generate a bash pre-receive hook that calls the companion eval.js once per
143 * pushed ref. Both file paths are embedded literals so the script is
144 * fully self-contained.
145 */
146function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string {
147 const D = "$";
148 return [
149 "#!/bin/bash",
150 "set -uo pipefail",
151 `EVAL_SCRIPT='${evalScriptPath}'`,
152 `RULES_JSON='${rulesJsonPath}'`,
153 "FAILED=0",
154 "",
155 `while IFS=' ' read -r OLD NEW REF; do`,
156 ` [[ "${D}NEW" =~ ^0+${D} ]] && continue`,
157 ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`,
158 ` LOG_RANGE="${D}NEW"`,
159 // Empty-tree SHA — safe diff base for new branches with no parent.
160 ` DIFF_BASE=4b825dc642cb6eb9a060e54bf8d69288fbee4904`,
161 ` else`,
162 ` LOG_RANGE="${D}OLD..${D}NEW"`,
163 ` DIFF_BASE="${D}OLD"`,
164 ` fi`,
165 "",
166 ` COMMITS_TMP=$(mktemp)`,
167 ` SIZES_TMP=$(mktemp)`,
168 ` PATHS_TMP=$(mktemp)`,
169 ` git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`,
170 ` git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
171 "",
172 // Build sizes file: "<bytes> <path>" per changed file.
173 ` while IFS= read -r FP; do`,
174 ` [ -z "${D}FP" ] && continue`,
175 ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
176 ` SZ=0`,
177 ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
178 ` printf '%s %s\\n' "${D}SZ" "${D}FP"`,
179 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
180 "",
181 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" 2>/dev/null || true)`,
182 "",
183 // Each output line is "<enforcement>\t<message>"; split on tab.
184 ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
185 ` [ -z "${D}MSG_OUT" ] && continue`,
186 ` echo "remote: ${D}MSG_OUT" >&2`,
187 ` [ "${D}ENFORCE" = "active" ] && FAILED=1`,
188 ` done <<< "${D}RESULT"`,
189 "",
190 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP"`,
191 `done`,
192 "",
193 `exit ${D}FAILED`,
194 ].join("\n") + "\n";
195}
196
197/**
198 * Write a pre-receive hook + companion rules JSON to a temp directory and
199 * return the git env vars that redirect git to use that hooks dir, plus a
200 * cleanup function.
201 *
202 * Callers must always invoke cleanup() — even on error — to remove the temp
203 * directory. Failure to set up the hook dir is non-fatal: we return null so
204 * the caller can proceed without pack-content inspection rather than wedging
205 * the push.
206 */
207export async function installPackInspectionHook(
208 rulesets: ActiveRuleset[]
209): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
210 // Collect pack-content rules from non-disabled rulesets.
211 type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
212 const commitMsgRules: RuleEntry[] = [];
213 const blockedPathRules: RuleEntry[] = [];
214 const maxSizeRules: RuleEntry[] = [];
215
216 for (const rs of rulesets) {
217 if (rs.enforcement === "disabled") continue;
218 for (const r of rs.rules) {
219 const p = parseParams(r.params);
220 const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p };
221 if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry);
222 else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry);
223 else if (r.ruleType === "max_file_size") maxSizeRules.push(entry);
224 }
225 }
226
227 // No pack-content rules → skip hook installation entirely.
228 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length) {
229 return null;
230 }
231
232 try {
233 const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-"));
234 const rulesJsonPath = join(dir, "rules.json");
235 await writeFile(
236 rulesJsonPath,
237 JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }),
238 { mode: 0o644 }
239 );
240 const evalScriptPath = join(dir, "eval.js");
241 await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 });
242 const hookPath = join(dir, "pre-receive");
243 await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 });
244 return {
245 env: {
246 GIT_CONFIG_COUNT: "1",
247 GIT_CONFIG_KEY_0: "core.hooksPath",
248 GIT_CONFIG_VALUE_0: dir,
249 },
250 cleanup: async () => {
251 try {
252 await rm(dir, { recursive: true, force: true });
253 } catch {
254 // best-effort
255 }
256 },
257 };
258 } catch {
259 return null;
260 }
261}
262
5dc83edClaude263const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
264
265/**
266 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
267 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
268 * as a branch since the evaluator's tag-only rules will gracefully no-op.
269 */
270function refType(refName: string): "branch" | "tag" {
271 return refName.startsWith("refs/tags/") ? "tag" : "branch";
272}
273
274/**
275 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
276 * and return the aggregated decision. Multiple violations across refs are
277 * concatenated so the user sees every problem in one push attempt rather
278 * than one-at-a-time.
279 */
280export async function evaluatePushPolicy(
281 args: PushPolicyArgs
282): Promise<PushPolicyResult> {
283 const { repositoryId, refs, pusherUserId } = args;
284 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
285
286 const violations: string[] = [];
287
288 // Protected tags — runs once per ref, only fires for tag refs.
289 for (const ref of refs) {
290 if (!ref.refName.startsWith("refs/tags/")) continue;
291 const tagName = ref.refName.slice("refs/tags/".length);
292 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
293 try {
294 protectedRule = await matchProtectedTag(repositoryId, tagName);
295 } catch {
296 protectedRule = null;
297 }
298 if (!protectedRule) continue;
299
300 // Anonymous pusher → never bypasses. Authenticated pusher must be the
301 // owner (or future tag-admin) for this repo.
302 let canBypass = false;
303 try {
304 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
305 } catch {
306 canBypass = false;
307 }
308 if (canBypass) continue;
309
310 const action =
311 ref.newSha === ZERO_SHA
312 ? "delete"
313 : ref.oldSha === ZERO_SHA
314 ? "create"
315 : "update";
316 violations.push(
317 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
318 );
319 }
320
321 // Rulesets — single DB call, evaluator runs purely on names.
322 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
323 try {
324 rulesets = await listRulesetsForRepo(repositoryId);
325 } catch {
326 rulesets = [];
327 }
328
329 if (rulesets && rulesets.length > 0) {
330 for (const ref of refs) {
331 const ctx: PushContext = {
332 kind: "push",
333 refType: refType(ref.refName),
334 refName: ref.refName,
335 commits: [],
336 // forcePush is left false — true detection requires a reachability
337 // check on the new commit, which is in the pack we haven't unpacked.
338 forcePush: false,
339 };
340 let result;
341 try {
342 result = evaluatePush(rulesets, ctx);
343 } catch {
344 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
345 }
346 if (!result.allowed && result.violations.length > 0) {
347 for (const v of result.violations) {
348 // Only "active" enforcement blocks; "evaluate" is dry-run.
349 if (v.enforcement !== "active") continue;
350 violations.push(
351 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
352 );
353 }
354 }
355 }
356 }
357
358 return violations.length === 0
359 ? ALLOW
360 : { allowed: false, violations };
361}
362
ec39211Claude363/**
364 * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and
365 * call `installPackInspectionHook`. Returns null on DB error or when there
366 * are no pack-content rules to enforce. Never throws.
367 */
368export async function installPackInspectionHookForRepo(
369 repositoryId: string
370): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
371 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
372 try {
373 rulesets = await listRulesetsForRepo(repositoryId);
374 } catch {
375 return null;
376 }
377 if (!rulesets.length) return null;
378 return installPackInspectionHook(rulesets);
379}
380
5dc83edClaude381/** Build a human-readable error body for the 403 response. */
382export function formatPolicyError(violations: string[]): string {
383 if (!violations || violations.length === 0) {
384 return "Push rejected by Gluecron policy.";
385 }
386 const lines = violations.map((v) => ` - ${v}`);
387 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
388}