Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitec39211unknown_key

Enforce commit_message_pattern, blocked_file_paths, max_file_size at push time

Enforce commit_message_pattern, blocked_file_paths, max_file_size at push time

Install a per-push pre-receive hook (via GIT_CONFIG_COUNT/core.hooksPath)
that runs git log, git diff --name-only, and git cat-file -s inside
git's quarantine window so violations reject the push without leaving
orphan objects.  Wired for both HTTP (serviceRpc) and SSH (pipeGitToChannel).

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: 98bc72c
4 files changed+28514ec39211bf1e834edb63848a3d4e8fedffb8dab3b
4 changed files+285−14
Modifiedsrc/git/protocol.ts+3−1View fileUnifiedSplit
5454 owner: string,
5555 repo: string,
5656 service: string,
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null,
58 extraEnv?: Record<string, string>
5859): Promise<Response> {
5960 const repoDir = getRepoPath(owner, repo);
6061 const inputBytes = body
6768 stdin: "pipe",
6869 stdout: "pipe",
6970 stderr: "pipe",
71 env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
7072 });
7173
7274 proc.stdin.write(inputBytes);
Modifiedsrc/lib/push-policy.ts+222−0View fileUnifiedSplit
2626 * Postgres hiccups.
2727 */
2828
29import { mkdtemp, writeFile, rm } from "fs/promises";
30import { join } from "path";
31import { tmpdir } from "os";
2932import {
3033 matchProtectedTag,
3134 canBypassProtectedTag,
3336import {
3437 listRulesetsForRepo,
3538 evaluatePush,
39 parseParams,
3640 type PushContext,
3741} from "./rulesets";
42import type { RulesetRule, RepoRuleset } from "../db/schema";
3843
3944export type RefUpdate = {
4045 oldSha: string;
4651 repositoryId: string;
4752 refs: RefUpdate[];
4853 pusherUserId: string | null;
54 /** Absolute path to the bare repo dir; enables pack-content inspection. */
55 repoPath?: string;
4956};
5057
5158export type PushPolicyResult = {
5663/** "0000000000000000000000000000000000000000" — 40 zeros. */
5764export const ZERO_SHA = "0".repeat(40);
5865
66// ----------------------------------------------------------------------------
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
59263const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
60264
61265/**
156360 : { allowed: false, violations };
157361}
158362
363/**
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
159381/** Build a human-readable error body for the 403 response. */
160382export function formatPolicyError(violations: string[]): string {
161383 if (!violations || violations.length === 0) {
Modifiedsrc/lib/ssh-server.ts+25−7View fileUnifiedSplit
4444import { repoExists } from "../git/repository";
4545import { invalidateRepoCache } from "./cache";
4646import { onPostReceive } from "../hooks/post-receive";
47import { evaluatePushPolicy, formatPolicyError } from "./push-policy";
47import { evaluatePushPolicy, formatPolicyError, installPackInspectionHookForRepo } from "./push-policy";
4848import {
4949 resolveRepoAccess,
5050 satisfiesAccess,
256256function pipeGitToChannel(
257257 service: string,
258258 absRepoPath: string,
259 channel: ServerChannel
259 channel: ServerChannel,
260 extraEnv?: Record<string, string>
260261): Promise<number> {
261262 return new Promise((resolve) => {
262263 const gitProc = spawn(service, [absRepoPath], {
263 env: { ...process.env, HOME: process.env.HOME ?? "/tmp" },
264 env: { ...process.env, HOME: process.env.HOME ?? "/tmp", ...extraEnv },
264265 });
265266
266267 // Client → git stdin
349350 return;
350351 }
351352
352 // Evaluate ref-name push policies (protected tags + active rulesets).
353 // We can only do name-pattern checks here; pack-content inspection is v2.
354 // We use show-ref diff instead of parsing the pack stream.
353 // Ref-name push policies run before the pack lands (name-only checks).
354 // Pack-content inspection is wired via a pre-receive hook so it runs
355 // inside git's quarantine window — same approach as the HTTP path.
355356 const refsBefore = await getShowRef(absRepoPath);
356357
357358 audit({
362363 targetId: repoInfo.id,
363364 }).catch(() => {});
364365
365 const exitCode = await pipeGitToChannel(service, absRepoPath, channel);
366 let hookEnv: Record<string, string> | undefined;
367 let hookCleanup: (() => Promise<void>) | undefined;
368 try {
369 const hook = await installPackInspectionHookForRepo(repoInfo.id);
370 if (hook) {
371 hookEnv = hook.env;
372 hookCleanup = hook.cleanup;
373 }
374 } catch {
375 // fail-open
376 }
377
378 let exitCode: number;
379 try {
380 exitCode = await pipeGitToChannel(service, absRepoPath, channel, hookEnv);
381 } finally {
382 hookCleanup?.().catch(() => {});
383 }
366384
367385 if (exitCode === 0) {
368386 const refsAfter = await getShowRef(absRepoPath);
Modifiedsrc/routes/git.ts+35−6View fileUnifiedSplit
1616import {
1717 evaluatePushPolicy,
1818 formatPolicyError,
19 installPackInspectionHookForRepo,
1920} from "../lib/push-policy";
2021import { resolvePusher } from "../lib/git-push-auth";
2122import { audit } from "../lib/notify";
9495
9596 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
9697 // on any DB hiccup (the helper returns {allowed:true} in that case).
98 // We also retain the repoRow so the pack-inspection hook below can reuse it.
99 let cachedRepoId: string | null = null;
97100 if (refs.length > 0) {
98101 try {
99102 const repoRow = await loadRepoRow(owner, repo);
100103 if (repoRow) {
104 cachedRepoId = repoRow.id;
101105 const pusher = await resolvePusher(c.req.header("authorization"));
102106 const decision = await evaluatePushPolicy({
103107 repositoryId: repoRow.id,
141145 }
142146 }
143147
144 const response = await serviceRpc(
145 owner,
146 repoRaw,
147 "git-receive-pack",
148 bodyBuffer
149 );
148 // Pack-content inspection: install a pre-receive hook that enforces
149 // commit_message_pattern, blocked_file_paths, and max_file_size rules.
150 // git-receive-pack runs the hook before promoting quarantined objects, so
151 // a hook exit-1 leaves the repo unchanged. We clean up the temp dir
152 // unconditionally after serviceRpc returns.
153 let hookEnv: Record<string, string> | undefined;
154 let hookCleanup: (() => Promise<void>) | undefined;
155 if (refs.length > 0 && cachedRepoId) {
156 try {
157 const hook = await installPackInspectionHookForRepo(cachedRepoId);
158 if (hook) {
159 hookEnv = hook.env;
160 hookCleanup = hook.cleanup;
161 }
162 } catch {
163 // fail-open
164 }
165 }
166
167 let response: Response;
168 try {
169 response = await serviceRpc(
170 owner,
171 repoRaw,
172 "git-receive-pack",
173 bodyBuffer,
174 hookEnv
175 );
176 } finally {
177 hookCleanup?.().catch(() => {});
178 }
150179
151180 // Invalidate cached git data for this repo immediately
152181 invalidateRepoCache(owner, repo);
153182