Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame487 lines · 3 contributors
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 *
91b054eccanty labs23 * Pack-content inspection also carries an unconditional secret scan (2026-07-16):
24 * previously a critical secret (AWS/GitHub/Anthropic/Stripe key, PEM private
25 * key) landed in the object store on any direct push and was only caught
26 * after the fact by the async post-receive scan in security-scan.ts, which
27 * can only react with a follow-up remediation commit — the secret was
28 * already pushed. The pre-receive hook now also scans changed-file content
29 * and rejects the push before objects are promoted, regardless of whether
30 * the repo has any rulesets configured. Kill switch: config.secretScanOnPushDisabled.
31 *
5dc83edClaude32 * Pure helpers + DB callers; never throws into the request path. On any
33 * unexpected failure we return {allowed:true} (fail-open) to preserve the
34 * existing no-policy behaviour rather than wedging legitimate pushes when
35 * Postgres hiccups.
36 */
37
ec39211Claude38import { mkdtemp, writeFile, rm } from "fs/promises";
39import { join } from "path";
40import { tmpdir } from "os";
5dc83edClaude41import {
42 matchProtectedTag,
43 canBypassProtectedTag,
44} from "./protected-tags";
45import {
46 listRulesetsForRepo,
47 evaluatePush,
ec39211Claude48 parseParams,
5dc83edClaude49 type PushContext,
50} from "./rulesets";
91b054eccanty labs51import { config } from "./config";
ec39211Claude52import type { RulesetRule, RepoRuleset } from "../db/schema";
5dc83edClaude53
54export type RefUpdate = {
55 oldSha: string;
56 newSha: string;
57 refName: string;
58};
59
60export type PushPolicyArgs = {
61 repositoryId: string;
62 refs: RefUpdate[];
63 pusherUserId: string | null;
ec39211Claude64 /** Absolute path to the bare repo dir; enables pack-content inspection. */
65 repoPath?: string;
5dc83edClaude66};
67
68export type PushPolicyResult = {
69 allowed: boolean;
70 violations: string[];
71};
72
73/** "0000000000000000000000000000000000000000" — 40 zeros. */
74export const ZERO_SHA = "0".repeat(40);
75
ec39211Claude76// ----------------------------------------------------------------------------
77// Pack-content inspection via pre-receive hook
78// ----------------------------------------------------------------------------
79
80type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };
81
91b054eccanty labs82/**
83 * High-signal, no-imports secret detectors used only inside the pre-receive
84 * hook sandbox (see buildEvalScript() below — that script runs standalone
85 * via `bun run` with zero project imports by design, so these patterns are
86 * intentionally a trimmed duplicate of security-scan.ts's SECRET_PATTERNS,
87 * restricted to the "critical" subset — the same threshold runAllGateChecks
88 * uses to hard-block a PR merge (gate.ts: criticalSecrets === 0). Kept in
89 * sync by src/__tests__/push-policy-secret-scan.test.ts, which asserts every
90 * critical pattern in security-scan.ts has a corresponding regex here.
91 */
92const HOOK_SECRET_PATTERNS: Array<{ type: string; source: string; flags: string }> = [
93 { type: "AWS Access Key", source: "\\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\\b", flags: "" },
94 { type: "AWS Secret Key", source: "aws(.{0,20})?(secret|access)?(.{0,20})?['\\\"]([A-Za-z0-9/+=]{40})['\\\"]", flags: "i" },
95 { type: "GitHub Token", source: "\\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\\b", flags: "" },
96 { type: "Anthropic API Key", source: "\\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\\b", flags: "" },
97 { type: "OpenAI API Key", source: "\\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\\b", flags: "" },
98 { type: "Stripe Key", source: "\\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\\b", flags: "" },
99 { type: "Private Key (PEM)", source: "-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----", flags: "" },
100];
101
102/** Exported for the sync test — the trimmed duplicate list above. */
103export function hookSecretPatternTypes(): string[] {
104 return HOOK_SECRET_PATTERNS.map((p) => p.type);
105}
106
107const HOOK_SKIP_PATH_RE =
108 "(^|/)(\\.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)$";
779c681ccantynz-alt109// Lines matching this are never treated as a leaked secret.
110//
111// `gluecron:allow-secret` is an explicit, reviewable opt-out pragma (the same
112// idea as gitleaks:allow). It exists because this repository's own test suite
113// must contain realistic-looking credentials in order to prove the scanner
114// detects them — without a pragma the scanner rejects its own fixtures and the
115// suite becomes unpushable. Prefer this over skip-listing whole directories,
116// which would silently stop scanning real code under those paths.
117const HOOK_PLACEHOLDER_RE =
118 "gluecron:allow-secret|example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme";
91b054eccanty labs119
ec39211Claude120/**
121 * JS source for the per-push evaluator that the pre-receive hook calls once
91b054eccanty labs122 * per ref. Receives four file-path arguments:
123 * argv[2] = rules.json path
124 * argv[3] = commits file (lines: "<sha> <subject>")
125 * argv[4] = sizes file (lines: "<bytes> <path>")
126 * argv[5] = contents file (lines: "<path>\t<base64 content>", size-capped)
ec39211Claude127 *
128 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
129 * Exits 0 always — the shell hook interprets the output.
130 */
131function buildEvalScript(): string {
132 // Written as a plain JS string so no TypeScript template interpolation occurs.
133 // Uses only Node/Bun built-ins; no imports from the Gluecron codebase.
134 return [
135 "import { readFileSync } from 'fs';",
136 "const rules = JSON.parse(readFileSync(process.argv[2], 'utf8'));",
137 "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);",
138 "const sizes = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);",
139 "const SEP = '\\t';",
140 "const out = [];",
141 "for (const line of commits) {",
142 " const sp = line.indexOf(' ');",
143 " if (sp < 0) continue;",
144 " const sha = line.slice(0, sp);",
145 " const msg = line.slice(sp + 1);",
146 " for (const r of rules.commitMsgRules) {",
147 " const pattern = String(r.params.pattern || '');",
148 " if (!pattern) continue;",
149 " let re;",
150 " try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }",
151 " const req = r.params.require !== false;",
152 " const ok = re.test(msg);",
153 " if (req && !ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');",
154 " if (!req && ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');",
155 " }",
156 "}",
157 "for (const line of sizes) {",
158 " const sp = line.indexOf(' ');",
159 " if (sp < 0) continue;",
160 " const sz = Number(line.slice(0, sp));",
161 " const fp = line.slice(sp + 1);",
162 " for (const r of rules.blockedPathRules) {",
163 " const globs = Array.isArray(r.params.paths) ? r.params.paths : [];",
164 " for (const g of globs) {",
165 " const parts = [];",
166 " let i = 0;",
167 " while (i < g.length) {",
168 " const ch = g[i];",
169 " if (ch === '*') {",
170 " if (g[i+1] === '*') { parts.push('.*'); i += 2; }",
171 " else { parts.push('[^/]*'); i++; }",
172 " } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {",
173 " parts.push('\\\\' + ch); i++;",
174 " } else { parts.push(ch); i++; }",
175 " }",
176 " const re = new RegExp('^' + parts.join('') + '$');",
177 " if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');",
178 " }",
179 " }",
180 " for (const r of rules.maxSizeRules) {",
181 " const limit = Number(r.params.bytes || 0);",
182 " if (limit && sz > limit)",
183 " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');",
184 " }",
185 "}",
91b054eccanty labs186 // Unconditional secret scan — independent of rules.json / rulesets.
187 // Only runs when argv[5] (contents file) was provided by the caller.
188 `const SECRET_PATTERNS = ${JSON.stringify(HOOK_SECRET_PATTERNS)}.map(p => ({ type: p.type, re: new RegExp(p.source, p.flags) }));`,
189 `const SKIP_RE = new RegExp(${JSON.stringify(HOOK_SKIP_PATH_RE)}, 'i');`,
190 `const PLACEHOLDER_RE = new RegExp(${JSON.stringify(HOOK_PLACEHOLDER_RE)}, 'i');`,
191 "if (process.argv[5]) {",
192 " let contentLines = [];",
193 " try { contentLines = readFileSync(process.argv[5], 'utf8').split('\\n').filter(Boolean); } catch { contentLines = []; }",
194 " for (const line of contentLines) {",
195 " const tab = line.indexOf('\\t');",
196 " if (tab < 0) continue;",
197 " const fp = line.slice(0, tab);",
198 " const b64 = line.slice(tab + 1);",
199 " if (SKIP_RE.test(fp)) continue;",
200 " let text;",
201 " try { text = Buffer.from(b64, 'base64').toString('utf8'); } catch { continue; }",
202 " const fileLines = text.split('\\n');",
203 " for (let i = 0; i < fileLines.length; i++) {",
204 " const l = fileLines[i];",
205 " if (PLACEHOLDER_RE.test(l)) continue;",
206 " for (const p of SECRET_PATTERNS) {",
207 " if (p.re.test(l)) {",
208 " 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.');",
209 " break;",
210 " }",
211 " }",
212 " }",
213 " }",
214 "}",
ec39211Claude215 "process.stdout.write(out.join('\\n'));",
216 ].join("\n") + "\n";
217}
218
219/**
220 * Generate a bash pre-receive hook that calls the companion eval.js once per
221 * pushed ref. Both file paths are embedded literals so the script is
222 * fully self-contained.
223 */
224function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string {
225 const D = "$";
226 return [
227 "#!/bin/bash",
228 "set -uo pipefail",
229 `EVAL_SCRIPT='${evalScriptPath}'`,
230 `RULES_JSON='${rulesJsonPath}'`,
231 "FAILED=0",
232 "",
233 `while IFS=' ' read -r OLD NEW REF; do`,
234 ` [[ "${D}NEW" =~ ^0+${D} ]] && continue`,
235 "",
236 ` COMMITS_TMP=$(mktemp)`,
237 ` SIZES_TMP=$(mktemp)`,
238 ` PATHS_TMP=$(mktemp)`,
91b054eccanty labs239 ` CONTENTS_TMP=$(mktemp)`,
74202f0ccantynz-alt240 ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`,
241 // New branch/ref: inspect ONLY the commits and paths that are not already
242 // reachable from an existing ref (--not --all). The previous empty-tree
243 // diff base ("git diff <empty-tree> NEW") reported EVERY file in the whole
244 // repo as changed, so pushing any new branch re-scanned pre-existing files
245 // the push never touched — a clean feature branch was rejected because an
246 // unrelated committed fixture matched a secret pattern. On a genuinely
247 // empty repo (no existing refs) --not --all excludes nothing, so the very
248 // first push is still fully scanned, which is correct.
249 ` git log --format="%H %s" "${D}NEW" --not --all 2>/dev/null > "${D}COMMITS_TMP" || true`,
250 ` git log --name-only --format="" "${D}NEW" --not --all 2>/dev/null | sort -u > "${D}PATHS_TMP" || true`,
251 ` else`,
252 ` git log --format="%H %s" "${D}OLD..${D}NEW" 2>/dev/null > "${D}COMMITS_TMP" || true`,
253 ` git diff --name-only "${D}OLD" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
254 ` fi`,
ec39211Claude255 "",
91b054eccanty labs256 // Build sizes file: "<bytes> <path>" per changed file. Also dump
257 // base64 content (capped at 256KB/file) for the secret scan below —
258 // a side write to CONTENTS_TMP, independent of this loop's own
259 // stdout redirect into SIZES_TMP.
ec39211Claude260 ` while IFS= read -r FP; do`,
261 ` [ -z "${D}FP" ] && continue`,
262 ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
263 ` SZ=0`,
264 ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
265 ` printf '%s %s\\n' "${D}SZ" "${D}FP"`,
91b054eccanty labs266 ` if [ -n "${D}BLOB" ] && [ "${D}SZ" -gt 0 ] && [ "${D}SZ" -le 262144 ]; then`,
267 ` 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')`,
268 ` [ -n "${D}B64" ] && printf '%s\\t%s\\n' "${D}FP" "${D}B64" >> "${D}CONTENTS_TMP"`,
269 ` fi`,
ec39211Claude270 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
271 "",
91b054eccanty labs272 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP" 2>/dev/null || true)`,
ec39211Claude273 "",
274 // Each output line is "<enforcement>\t<message>"; split on tab.
275 ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
276 ` [ -z "${D}MSG_OUT" ] && continue`,
277 ` echo "remote: ${D}MSG_OUT" >&2`,
278 ` [ "${D}ENFORCE" = "active" ] && FAILED=1`,
279 ` done <<< "${D}RESULT"`,
280 "",
91b054eccanty labs281 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP"`,
ec39211Claude282 `done`,
283 "",
284 `exit ${D}FAILED`,
285 ].join("\n") + "\n";
286}
287
288/**
289 * Write a pre-receive hook + companion rules JSON to a temp directory and
290 * return the git env vars that redirect git to use that hooks dir, plus a
291 * cleanup function.
292 *
293 * Callers must always invoke cleanup() — even on error — to remove the temp
294 * directory. Failure to set up the hook dir is non-fatal: we return null so
295 * the caller can proceed without pack-content inspection rather than wedging
296 * the push.
297 */
298export async function installPackInspectionHook(
91b054eccanty labs299 rulesets: ActiveRuleset[],
300 opts: { secretScan?: boolean } = {}
ec39211Claude301): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
91b054eccanty labs302 const secretScan = opts.secretScan !== false;
303
ec39211Claude304 // Collect pack-content rules from non-disabled rulesets.
305 type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
306 const commitMsgRules: RuleEntry[] = [];
307 const blockedPathRules: RuleEntry[] = [];
308 const maxSizeRules: RuleEntry[] = [];
309
310 for (const rs of rulesets) {
311 if (rs.enforcement === "disabled") continue;
312 for (const r of rs.rules) {
313 const p = parseParams(r.params);
314 const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p };
315 if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry);
316 else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry);
317 else if (r.ruleType === "max_file_size") maxSizeRules.push(entry);
318 }
319 }
320
91b054eccanty labs321 // No pack-content rules AND secret scan disabled → skip hook entirely.
322 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) {
ec39211Claude323 return null;
324 }
325
326 try {
327 const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-"));
328 const rulesJsonPath = join(dir, "rules.json");
329 await writeFile(
330 rulesJsonPath,
331 JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }),
332 { mode: 0o644 }
333 );
334 const evalScriptPath = join(dir, "eval.js");
335 await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 });
336 const hookPath = join(dir, "pre-receive");
337 await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 });
338 return {
339 env: {
340 GIT_CONFIG_COUNT: "1",
341 GIT_CONFIG_KEY_0: "core.hooksPath",
342 GIT_CONFIG_VALUE_0: dir,
343 },
344 cleanup: async () => {
345 try {
346 await rm(dir, { recursive: true, force: true });
347 } catch {
348 // best-effort
349 }
350 },
351 };
352 } catch {
353 return null;
354 }
355}
356
5dc83edClaude357const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
358
359/**
360 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
361 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
362 * as a branch since the evaluator's tag-only rules will gracefully no-op.
363 */
364function refType(refName: string): "branch" | "tag" {
365 return refName.startsWith("refs/tags/") ? "tag" : "branch";
366}
367
368/**
369 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
370 * and return the aggregated decision. Multiple violations across refs are
371 * concatenated so the user sees every problem in one push attempt rather
372 * than one-at-a-time.
373 */
374export async function evaluatePushPolicy(
375 args: PushPolicyArgs
376): Promise<PushPolicyResult> {
377 const { repositoryId, refs, pusherUserId } = args;
378 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
379
380 const violations: string[] = [];
381
382 // Protected tags — runs once per ref, only fires for tag refs.
383 for (const ref of refs) {
384 if (!ref.refName.startsWith("refs/tags/")) continue;
385 const tagName = ref.refName.slice("refs/tags/".length);
386 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
387 try {
388 protectedRule = await matchProtectedTag(repositoryId, tagName);
389 } catch {
390 protectedRule = null;
391 }
392 if (!protectedRule) continue;
393
394 // Anonymous pusher → never bypasses. Authenticated pusher must be the
395 // owner (or future tag-admin) for this repo.
396 let canBypass = false;
397 try {
398 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
399 } catch {
400 canBypass = false;
401 }
402 if (canBypass) continue;
403
404 const action =
405 ref.newSha === ZERO_SHA
406 ? "delete"
407 : ref.oldSha === ZERO_SHA
408 ? "create"
409 : "update";
410 violations.push(
411 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
412 );
413 }
414
415 // Rulesets — single DB call, evaluator runs purely on names.
416 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
417 try {
418 rulesets = await listRulesetsForRepo(repositoryId);
419 } catch {
420 rulesets = [];
421 }
422
423 if (rulesets && rulesets.length > 0) {
424 for (const ref of refs) {
425 const ctx: PushContext = {
426 kind: "push",
427 refType: refType(ref.refName),
428 refName: ref.refName,
429 commits: [],
430 // forcePush is left false — true detection requires a reachability
431 // check on the new commit, which is in the pack we haven't unpacked.
432 forcePush: false,
433 };
434 let result;
435 try {
436 result = evaluatePush(rulesets, ctx);
437 } catch {
438 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
439 }
440 if (!result.allowed && result.violations.length > 0) {
441 for (const v of result.violations) {
442 // Only "active" enforcement blocks; "evaluate" is dry-run.
443 if (v.enforcement !== "active") continue;
444 violations.push(
445 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
446 );
447 }
448 }
449 }
450 }
451
452 return violations.length === 0
453 ? ALLOW
454 : { allowed: false, violations };
455}
456
ec39211Claude457/**
458 * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and
459 * call `installPackInspectionHook`. Returns null on DB error or when there
460 * are no pack-content rules to enforce. Never throws.
461 */
462export async function installPackInspectionHookForRepo(
463 repositoryId: string
464): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
465 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
466 try {
467 rulesets = await listRulesetsForRepo(repositoryId);
468 } catch {
91b054eccanty labs469 rulesets = [];
ec39211Claude470 }
91b054eccanty labs471 // Always install — even with zero rulesets — so the unconditional secret
472 // scan still runs. Only a listRulesetsForRepo throw plus secret-scan being
473 // disabled entirely skips the hook (handled by installPackInspectionHook's
474 // own early-return when both inputs are empty).
475 return installPackInspectionHook(rulesets, {
476 secretScan: !config.secretScanOnPushDisabled,
477 });
ec39211Claude478}
479
5dc83edClaude480/** Build a human-readable error body for the 403 response. */
481export function formatPolicyError(violations: string[]): string {
482 if (!violations || violations.length === 0) {
483 return "Push rejected by Gluecron policy.";
484 }
485 const lines = violations.map((v) => ` - ${v}`);
486 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
487}