swadhinbiswas Add advanced monorepo path-scope management and enforcement · 2/19/2026
f321d69
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
import { getDatabase, schema } from "@/db";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { logger } from "@/lib/logger";
import type { APIRoute } from "astro";
import { eq, and } from "drizzle-orm";
import { resolveRepoPath } from "@/lib/git-storage";
import { checkPathPermissions } from "@/lib/path-scoping";
export const POST: APIRoute = async ({ request, url }) => {
const hookSecret = process.env.INTERNAL_HOOK_SECRET;
if (!hookSecret) {
return new Response("Server misconfigured", { status: 500 });
}
const providedSecret = request.headers.get("X-Hook-Secret");
if (providedSecret !== hookSecret) {
return new Response("Unauthorized", { status: 401 });
}
const repoPath = url.searchParams.get("repo");
if (!repoPath) {
return new Response("Missing repo path", { status: 400 });
}
let body: { oldrev: string; newrev: string; refname: string; pusher?: string };
try {
body = await request.json();
} catch (e) {
return new Response("Invalid JSON", { status: 400 });
}
const { refname, newrev, oldrev, pusher } = body;
const userId = pusher; // The SSH server sends REMOTE_USER as userId
const isSystemPush = userId === "system" || userId === "internal-bot";
// We enforce branch protection only for branch refs.
if (!refname.startsWith("refs/heads/")) {
return new Response("OK", { status: 200 });
}
const branchName = refname.slice("refs/heads/".length);
const db = getDatabase() as NodePgDatabase<typeof schema>;
// Extract owner and repo name from path for flexible lookup
// Path formats:
// - local: /path/to/data/repos/owner/repo.git
// - cloud diskPath: repos/owner/repo.git
// - temp path: /path/to/.tmp/repos/owner/repo.git
const pathParts = repoPath.split('/');
const repoGit = pathParts.pop() || '';
const repoName = repoGit.replace('.git', '');
const owner = pathParts.pop() || '';
// Try finding by exact diskPath first
let repo = await db.query.repositories.findFirst({
where: eq(schema.repositories.diskPath, repoPath),
with: { owner: true }
});
// If not found, try cloud storage path format
if (!repo && owner && repoName) {
const cloudPath = `repos/${owner}/${repoName}.git`;
repo = await db.query.repositories.findFirst({
where: eq(schema.repositories.diskPath, cloudPath),
with: { owner: true }
});
}
if (!repo) {
return new Response("Repo not found", { status: 404 });
}
const localRepoPath = await resolveRepoPath(repo.diskPath);
const pusherUser = userId
? await db.query.users.findFirst({
where: eq(schema.users.id, userId),
columns: { id: true, isAdmin: true },
})
: null;
const isPrivilegedPush = isSystemPush || !!pusherUser?.isAdmin;
// Fetch protection rules
const rules = await db.query.branchProtection.findMany({
where: and(
eq(schema.branchProtection.repositoryId, repo.id),
eq(schema.branchProtection.active, true)
)
});
const matchingRule = rules.find(rule => {
// Simple exact match or wildcard
if (rule.pattern === branchName) return true;
if (rule.pattern.endsWith("*")) {
return branchName.startsWith(rule.pattern.slice(0, -1));
}
return false;
});
if (!matchingRule) {
return new Response("OK", { status: 200 });
}
// Enforce rules
// 1. Require PR
if (matchingRule.requiresPr) {
if (isPrivilegedPush) {
return new Response("OK", { status: 200 });
}
// If newrev allows force push (0000 -> sha is Create, Sha -> 0000 is Delete)
const isDelete = newrev === "0000000000000000000000000000000000000000";
if (isDelete) {
// Prevent deleting protected branch?
return new Response(`Deleting protected branch '${branchName}' is not allowed.`, { status: 403 });
}
// Check if this push is allowed directly?
// Typically "Require PR" means you can't push directly to this branch.
// ALWAYS REJECT direct pushes if Requires PR is on.
// UNLESS the user is an admin? Or bypass list?
// For OpenCodeHub V1, let's strict block.
// EXCEPTION: If this push is actually a MERGE from a PR?
// But we are in pre-receive. How do we know?
// System pushes (merges) usually bypass hooks or have a special user.
// Since our Merge Queue triggers merges on the server, does it run pre-receive?
// git operations on local file system often interact with hooks.
// But typically `git update-ref` does NOT trigger hooks.
// Our `mergeBranch` uses `git merge-tree` + `update-ref` (or `commit-tree` + `update-ref`).
// So server-side merges should be SAFE.
// This hook is triggered by `git receive-pack` (SSH/HTTP push from user).
return new Response(`Protected branch '${branchName}' requires a Pull Request. Direct pushes are disabled.`, { status: 403 });
}
// 2. Prevent Force Push
if (!matchingRule.allowForcePushes) {
if (isPrivilegedPush) {
return new Response("OK", { status: 200 });
}
// We need to check if it's a force push.
// git rev-list oldrev ^newrev (if output non-empty, it's non-fast-forward?)
// Actually simpler: `git merge-base --is-ancestor oldrev newrev`
// If exit code 0, it is fast-forward. If 1, it's force push (history rewrite/divergence).
// Note: `oldrev` 0000... is creation (always allowed as fast-forward technically).
if (oldrev !== "0000000000000000000000000000000000000000" && newrev !== "0000000000000000000000000000000000000000") {
try {
// We need to run git command here.
// This is expensive for an API request? Maybe. But necessary.
// We can use simple-git.
const { simpleGit } = await import("simple-git");
const git = simpleGit(localRepoPath);
try {
// merge-base --is-ancestor oldrev newrev
await git.raw(["merge-base", "--is-ancestor", oldrev, newrev]);
} catch (err) {
// If it fails, it means not ancestor -> Force push!
return new Response(`Force pushes to protected branch '${branchName}' are not allowed.`, { status: 403 });
}
} catch (e) {
logger.error({ err: e }, "Failed to check force push status");
// Fail safe or open? Fail safe -> Block
return new Response("Failed to verify push safety.", { status: 500 });
}
}
}
// 3. Path-based Write Protection
const strictPathPermissions = await db.query.repositoryPathPermissions.findMany({
where: eq(schema.repositoryPathPermissions.repositoryId, repo.id)
});
if (strictPathPermissions.length > 0) {
if (!userId || userId === "anonymous") {
return new Response("Write access denied: User identity required for protected repository.", { status: 403 });
}
let changedFiles: string[] = [];
try {
const { simpleGit } = await import("simple-git");
const git = simpleGit(localRepoPath);
// Determine base revision for diff
let baseRev = oldrev;
// Handle new branch creation (oldrev is zero)
if (baseRev === "0000000000000000000000000000000000000000") {
const defaultBranch = repo.defaultBranch || "main";
// If creating default branch itself, diff against empty tree
if (branchName === defaultBranch) {
baseRev = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; // Empty tree hash
} else {
// Try to find the split point (merge-base) between new branch and default
// But newrev is the tip.
// Simply diffing against default branch on server is reasonable for permissions.
// "What are you changing relative to main?"
try {
// We need to resolve the default branch ref to SHA
const resolved = await git.revparse([defaultBranch]);
baseRev = resolved.trim();
} catch (e) {
// Default branch might not exist yet or error
baseRev = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
}
}
}
// Calculate changed files
// If deleting branch, newrev is zero, we skip check (deletion handled by branch protection)
if (newrev !== "0000000000000000000000000000000000000000") {
// Use --name-only to get file list
const diff = await git.diff([
"--name-only",
baseRev,
newrev
]);
changedFiles = diff.split('\n').filter(Boolean);
}
} catch (e) {
logger.error({ err: e }, "Failed to get changed files for path protection");
// Fail open or closed? Security usually Fail Closed.
return new Response("Internal Server Error during permission check", { status: 500 });
}
if (changedFiles.length > 0) {
const permission = await checkPathPermissions(userId, repo.id, changedFiles, "write");
if (!permission.allowed) {
return new Response(permission.reason || "Write access denied by path permissions.", { status: 403 });
}
}
}
return new Response("OK", { status: 200 });
};