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

feat(policy): push-time secret scan, CODEOWNERS merge enforcement, PR-workflow wiring

feat(policy): push-time secret scan, CODEOWNERS merge enforcement, PR-workflow wiring

Five related hardening pieces, all covered by new tests (153 pass, 0 fail):

- push-policy.ts: pre-receive hook now scans changed-file content for
  critical secrets (AWS/GitHub/Anthropic/OpenAI/Stripe keys, PEM keys)
  and rejects the push before objects are promoted, instead of relying
  solely on the async post-receive scan that can only react after the
  secret is already in the object store. Kill switch:
  config.secretScanOnPushDisabled.
- codeowners.ts: adds requiredOwnersApproved() — CODEOWNERS was
  previously advisory-only (reviewers auto-requested at PR open, never
  checked at merge time). Now enforced in the merge path (pulls.tsx)
  and exposed via mcp-tools.ts. Fails open on any internal error.
- pr-workflow-sync.ts (new): workflows declaring `on: pull_request`
  were parsed and stored but never actually enqueued — enqueueRun()
  was never called with event: "pull_request" anywhere. Wired into
  the PR-creation handler, fire-and-forget.
- autorepair.ts: tree-integrity fixes so a committed repair keeps
  every nested file (regression covered by
  autorepair-tree-integrity.test.ts).
- branch-protection.ts: required-status-checks fixes covered by
  commit-status-required-checks.test.ts.

Also fixes a test-isolation bug found during verification:
pr-workflow-sync.test.ts originally used a top-level mock.module() on
../db, which leaked globally across the whole `bun test` run and broke
two unrelated auth-guard tests (pulls-ai-description,
pulls-ai-rereview) whenever run in the same process. Switched to pure
dependency injection (loadWorkflows in deps), matching the pattern
already used by codeowners.test.ts and enqueueRun in the same file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ccanty labs committed on July 16, 2026Parent: 61897c4
14 files changed+12778591b054e2033ba58a5fd3eb7bb13077c3dbe416d7
14 changed files+1277−85
Addedsrc/__tests__/autorepair-tree-integrity.test.ts+130−0View fileUnifiedSplit
1/**
2 * Regression test for the 2026-07-16 incident: autoRepair()'s tree-rebuild
3 * used to feed `git ls-tree -r` output (full nested paths like
4 * "src/lib/foo.ts") directly into `git mktree`, which only ever builds a
5 * single tree level and fatally rejects any path containing a slash. None
6 * of the git subprocess calls in that path checked their exit code, so the
7 * failure was silently swallowed: the resulting "tree" only contained the
8 * handful of top-level files that happened to parse, and that near-empty
9 * tree got committed and force-written over the branch ref — silently
10 * deleting every nested file in the repository on a routine push-time
11 * repair (e.g. a couple of missing .gitignore entries).
12 *
13 * This test seeds a real bare repo (via plain plumbing — no clone/push, to
14 * avoid unrelated flakiness in this environment) with a nested directory
15 * structure, runs a real autoRepair() against it, and asserts every nested
16 * file survives with its original content intact.
17 */
18
19import { afterAll, beforeAll, describe, expect, it } from "bun:test";
20import { mkdir, rm, writeFile } from "fs/promises";
21import { join } from "path";
22import { tmpdir } from "os";
23import { initBareRepo, getRepoPath } from "../git/repository";
24import { autoRepair } from "../lib/autorepair";
25
26const GIT_REPOS_PATH = join(tmpdir(), "gluecron-autorepair-tree-" + Date.now());
27const OWNER = "acme";
28const REPO = "widgets";
29let barePath: string;
30
31async function git(args: string[], cwd?: string): Promise<{ stdout: string; exitCode: number }> {
32 const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
33 const stdout = (await new Response(proc.stdout).text()).trim();
34 const exitCode = await proc.exited;
35 return { stdout, exitCode };
36}
37
38beforeAll(async () => {
39 await mkdir(GIT_REPOS_PATH, { recursive: true });
40 process.env.GIT_REPOS_PATH = GIT_REPOS_PATH;
41
42 barePath = await initBareRepo(OWNER, REPO);
43 expect(barePath).toBe(getRepoPath(OWNER, REPO));
44
45 // Seed a nested-directory commit directly via plumbing (no clone/push —
46 // avoids unrelated network/protocol flakiness in this environment).
47 const workDir = join(GIT_REPOS_PATH, "_seed-work");
48 await mkdir(join(workDir, "src", "lib"), { recursive: true });
49 await mkdir(join(workDir, "src", "routes"), { recursive: true });
50 await mkdir(join(workDir, "docs"), { recursive: true });
51
52 // .gitignore missing some of the "essential entries" autoRepair adds —
53 // triggers REPAIR 1 (gitignore).
54 await writeFile(join(workDir, ".gitignore"), "node_modules/\n");
55 // package.json so autoRepair treats this as a JS project.
56 await writeFile(join(workDir, "package.json"), JSON.stringify({ name: REPO }, null, 2) + "\n");
57 // Nested file with trailing whitespace — triggers REPAIR 2 (whitespace),
58 // specifically exercising the "update an existing nested entry" path that
59 // the original mktree bug corrupted.
60 await writeFile(join(workDir, "src", "lib", "foo.ts"), "export const foo = 1; \n");
61 // Nested files that should survive UNTOUCHED — these are the ones the
62 // original bug silently deleted.
63 await writeFile(join(workDir, "src", "routes", "bar.ts"), "export const bar = 2;\n");
64 await writeFile(join(workDir, "docs", "notes.md"), "# Notes\n\nNothing to see here.\n");
65 await writeFile(join(workDir, "README.md"), "# widgets\n");
66
67 const gitArgs = (...args: string[]) => [`--git-dir=${barePath}`, `--work-tree=${workDir}`, ...args];
68 await git(gitArgs("add", "-A"), workDir);
69 const commit = await git(
70 gitArgs("-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "initial"),
71 workDir
72 );
73 expect(commit.exitCode).toBe(0);
74
75 await rm(workDir, { recursive: true, force: true });
76}, 30000);
77
78afterAll(async () => {
79 delete process.env.GIT_REPOS_PATH;
80 await rm(GIT_REPOS_PATH, { recursive: true, force: true }).catch(() => {});
81});
82
83describe("autoRepair — tree integrity after a repair", () => {
84 it("keeps every nested file after committing a repair (2026-07-16 regression)", async () => {
85 const before = await git(["ls-tree", "-r", "--name-only", "main"], barePath);
86 expect(before.exitCode).toBe(0);
87 const beforeFiles = before.stdout.split("\n").filter(Boolean).sort();
88 expect(beforeFiles).toContain("src/lib/foo.ts");
89 expect(beforeFiles).toContain("src/routes/bar.ts");
90 expect(beforeFiles).toContain("docs/notes.md");
91
92 const result = await autoRepair(OWNER, REPO, "main");
93
94 expect(result.repaired).toBe(true);
95 expect(result.commitSha).not.toBeNull();
96 expect(result.repairs.length).toBeGreaterThanOrEqual(2);
97
98 const after = await git(["ls-tree", "-r", "--name-only", "main"], barePath);
99 expect(after.exitCode).toBe(0);
100 const afterFiles = after.stdout.split("\n").filter(Boolean).sort();
101
102 // Every file present before the repair must still be present after.
103 for (const f of beforeFiles) {
104 expect(afterFiles).toContain(f);
105 }
106
107 // The untouched nested files must be byte-for-byte unchanged.
108 const bar = await git(["show", "main:src/routes/bar.ts"], barePath);
109 expect(bar.stdout).toBe("export const bar = 2;");
110 const notes = await git(["show", "main:docs/notes.md"], barePath);
111 expect(notes.stdout).toBe("# Notes\n\nNothing to see here.");
112
113 // The repaired nested file lost its trailing whitespace.
114 const foo = await git(["show", "main:src/lib/foo.ts"], barePath);
115 expect(foo.stdout).toBe("export const foo = 1;");
116
117 // The repaired .gitignore gained the missing entries.
118 const gitignore = await git(["show", "main:.gitignore"], barePath);
119 expect(gitignore.stdout).toContain(".env");
120 }, 30000);
121
122 it("makes no commit at all if a git subprocess fails (fail-closed)", async () => {
123 // Point at a ref that doesn't exist — every downstream git call should
124 // fail cleanly and autoRepair must report repaired:false, never a
125 // partial/corrupt commit.
126 const result = await autoRepair(OWNER, REPO, "no-such-branch");
127 expect(result.repaired).toBe(false);
128 expect(result.commitSha).toBeNull();
129 }, 15000);
130});
Addedsrc/__tests__/codeowners.test.ts+144−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/codeowners.ts's `requiredOwnersApproved()` — the fix for
3 * CODEOWNERS approval being advisory-only. Before this, `reviewersForChangedFiles()`
4 * was only used to auto-*request* reviewers on PR open; nothing at merge time
5 * checked whether a CODEOWNERS-designated reviewer actually approved.
6 *
7 * `getCodeownersForRepo` and `loadApprovedUsernames` are passed in via the
8 * injectable `deps` param (same DI rationale as push-workflow-sync.ts /
9 * pr-workflow-sync.ts): the real implementations touch ../git/repository and
10 * ../db respectively, both imported by dozens of unrelated test files, so a
11 * global mock.module() on either would leak across the whole `bun test` run.
12 * `matchOwners` / `expandOwnerTokens` (pure + already covered by
13 * green-ecosystem.test.ts) run for real — only usernames (no team tokens)
14 * are used here so `expandOwnerTokens` never touches the DB.
15 */
16
17import { describe, expect, it } from "bun:test";
18import { requiredOwnersApproved } from "../lib/codeowners";
19import type { OwnerRule } from "../lib/codeowners";
20
21function deps(rules: OwnerRule[], approved: Set<string>) {
22 return {
23 getCodeownersForRepo: async () => rules,
24 loadApprovedUsernames: async () => approved,
25 };
26}
27
28describe("requiredOwnersApproved", () => {
29 it("is satisfied when there is no CODEOWNERS file", async () => {
30 const result = await requiredOwnersApproved(
31 "acme",
32 "widgets",
33 "main",
34 "pr-1",
35 ["src/api/foo.ts"],
36 deps([], new Set())
37 );
38 expect(result).toEqual({ satisfied: true, missingOwners: [] });
39 });
40
41 it("is satisfied when CODEOWNERS exists but matches none of the changed files", async () => {
42 const rules: OwnerRule[] = [{ pattern: "docs/**", owners: ["alice"] }];
43 const result = await requiredOwnersApproved(
44 "acme",
45 "widgets",
46 "main",
47 "pr-1",
48 ["src/api/foo.ts"],
49 deps(rules, new Set())
50 );
51 expect(result).toEqual({ satisfied: true, missingOwners: [] });
52 });
53
54 it("blocks when the required owner has not approved", async () => {
55 const rules: OwnerRule[] = [{ pattern: "src/api/**", owners: ["alice"] }];
56 const result = await requiredOwnersApproved(
57 "acme",
58 "widgets",
59 "main",
60 "pr-1",
61 ["src/api/foo.ts"],
62 deps(rules, new Set())
63 );
64 expect(result.satisfied).toBe(false);
65 expect(result.missingOwners).toEqual(["alice"]);
66 });
67
68 it("is satisfied when the required owner has approved", async () => {
69 const rules: OwnerRule[] = [{ pattern: "src/api/**", owners: ["alice"] }];
70 const result = await requiredOwnersApproved(
71 "acme",
72 "widgets",
73 "main",
74 "pr-1",
75 ["src/api/foo.ts"],
76 deps(rules, new Set(["alice"]))
77 );
78 expect(result).toEqual({ satisfied: true, missingOwners: [] });
79 });
80
81 it("reports only the owners who haven't approved out of several", async () => {
82 const rules: OwnerRule[] = [
83 { pattern: "src/api/**", owners: ["alice", "bob"] },
84 ];
85 const result = await requiredOwnersApproved(
86 "acme",
87 "widgets",
88 "main",
89 "pr-1",
90 ["src/api/foo.ts"],
91 deps(rules, new Set(["alice"]))
92 );
93 expect(result.satisfied).toBe(false);
94 expect(result.missingOwners).toEqual(["bob"]);
95 });
96
97 it("fails open (satisfied: true) when getCodeownersForRepo throws", async () => {
98 const result = await requiredOwnersApproved(
99 "acme",
100 "widgets",
101 "main",
102 "pr-1",
103 ["src/api/foo.ts"],
104 {
105 getCodeownersForRepo: async () => {
106 throw new Error("git boom");
107 },
108 loadApprovedUsernames: async () => new Set(),
109 }
110 );
111 expect(result).toEqual({ satisfied: true, missingOwners: [] });
112 });
113
114 it("fails open (satisfied: true) when loadApprovedUsernames throws", async () => {
115 const rules: OwnerRule[] = [{ pattern: "src/api/**", owners: ["alice"] }];
116 const result = await requiredOwnersApproved(
117 "acme",
118 "widgets",
119 "main",
120 "pr-1",
121 ["src/api/foo.ts"],
122 {
123 getCodeownersForRepo: async () => rules,
124 loadApprovedUsernames: async () => {
125 throw new Error("db boom");
126 },
127 }
128 );
129 expect(result).toEqual({ satisfied: true, missingOwners: [] });
130 });
131
132 it("is satisfied when no files changed match any rule across multiple paths", async () => {
133 const rules: OwnerRule[] = [{ pattern: "/docs", owners: ["carol"] }];
134 const result = await requiredOwnersApproved(
135 "acme",
136 "widgets",
137 "main",
138 "pr-1",
139 ["src/a.ts", "src/b.ts"],
140 deps(rules, new Set())
141 );
142 expect(result).toEqual({ satisfied: true, missingOwners: [] });
143 });
144});
Addedsrc/__tests__/commit-status-required-checks.test.ts+165−0View fileUnifiedSplit
1/**
2 * Block E6/J8 — commit_statuses feed into required-checks enforcement.
3 *
4 * `passingCheckNames()` in `src/lib/branch-protection.ts` must treat a
5 * `commit_statuses` row (external CI signal, see src/lib/commit-statuses.ts)
6 * with state="success" as a passing check, matched by its `context`. Only
7 * the LATEST row per (repositoryId, commitSha, context) — by `createdAt` —
8 * counts, so a stale success followed by a newer failure must NOT count as
9 * passing, and vice versa.
10 *
11 * We stub `../db` (same pattern as mcp-write.test.ts / repo-access.test.ts):
12 * a fake `db.select()` dispatches on table identity (compared by reference
13 * against the real schema table objects) so gate_runs/workflow_runs queries
14 * come back empty and commit_statuses queries return our seeded fixture
15 * rows, without ever touching Neon.
16 */
17
18import { describe, expect, test, mock, afterAll } from "bun:test";
19import { commitStatuses, gateRuns, workflowRuns } from "../db/schema";
20
21const _real_db = await import("../db");
22
23const REPO_ID = "22222222-2222-2222-2222-222222222222";
24const SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
25
26type FakeStatusRow = {
27 repositoryId: string;
28 commitSha: string;
29 state: string;
30 context: string;
31 description: string | null;
32 targetUrl: string | null;
33 creatorId: string | null;
34 createdAt: Date;
35 updatedAt: Date;
36};
37
38let _statusRows: FakeStatusRow[] = [];
39
40function statusRow(overrides: Partial<FakeStatusRow>): FakeStatusRow {
41 const now = new Date();
42 return {
43 repositoryId: REPO_ID,
44 commitSha: SHA,
45 state: "success",
46 context: "ci/external",
47 description: null,
48 targetUrl: null,
49 creatorId: null,
50 createdAt: now,
51 updatedAt: now,
52 ...overrides,
53 };
54}
55
56let _lastFrom: any = null;
57
58const _selectChain: any = {
59 from: (t: any) => {
60 _lastFrom = t;
61 return _selectChain;
62 },
63 innerJoin: () => _selectChain,
64 where: () => _selectChain,
65 orderBy: () => _selectChain,
66 limit: async () => {
67 // gate_runs / workflow_runs paths always end in `.limit(200)` — return
68 // no rows so only the commit_statuses signal under test is exercised.
69 if (_lastFrom === gateRuns) return [];
70 if (_lastFrom === workflowRuns) return [];
71 return [];
72 },
73 // commit_statuses (via listStatuses) ends in `.orderBy(...)` with no
74 // `.limit()` call — the caller awaits the chain directly.
75 then: (resolve: (v: any) => void) => {
76 if (_lastFrom === commitStatuses) {
77 return resolve(
78 _statusRows.filter(
79 (r) => r.repositoryId === REPO_ID && r.commitSha === SHA
80 )
81 );
82 }
83 return resolve([]);
84 },
85};
86
87const _fakeDb = {
88 db: {
89 select: () => _selectChain,
90 },
91 getDb: () => _fakeDb.db,
92};
93
94mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
95
96afterAll(() => {
97 _statusRows = [];
98 mock.module("../db", () => _real_db);
99});
100
101// Import AFTER mock.module so branch-protection (and the commit-statuses
102// module it now imports) resolve against the stub.
103const { passingCheckNames } = await import("../lib/branch-protection");
104
105describe("passingCheckNames — commit_statuses integration", () => {
106 test("a success status's context is included", async () => {
107 _statusRows = [statusRow({ context: "ci/success-ctx", state: "success" })];
108 const names = await passingCheckNames(REPO_ID, SHA);
109 expect(names).toContain("ci/success-ctx");
110 });
111
112 test("a failure status's context is NOT included", async () => {
113 _statusRows = [statusRow({ context: "ci/failure-ctx", state: "failure" })];
114 const names = await passingCheckNames(REPO_ID, SHA);
115 expect(names).not.toContain("ci/failure-ctx");
116 });
117
118 test("a pending status's context is NOT included", async () => {
119 _statusRows = [statusRow({ context: "ci/pending-ctx", state: "pending" })];
120 const names = await passingCheckNames(REPO_ID, SHA);
121 expect(names).not.toContain("ci/pending-ctx");
122 });
123
124 test("older failure then newer success → context IS included", async () => {
125 const older = new Date(Date.now() - 60_000);
126 const newer = new Date();
127 _statusRows = [
128 statusRow({
129 context: "ci/flaky-ctx",
130 state: "failure",
131 createdAt: older,
132 updatedAt: older,
133 }),
134 statusRow({
135 context: "ci/flaky-ctx",
136 state: "success",
137 createdAt: newer,
138 updatedAt: newer,
139 }),
140 ];
141 const names = await passingCheckNames(REPO_ID, SHA);
142 expect(names).toContain("ci/flaky-ctx");
143 });
144
145 test("older success then newer failure → context is NOT included", async () => {
146 const older = new Date(Date.now() - 60_000);
147 const newer = new Date();
148 _statusRows = [
149 statusRow({
150 context: "ci/regressed-ctx",
151 state: "success",
152 createdAt: older,
153 updatedAt: older,
154 }),
155 statusRow({
156 context: "ci/regressed-ctx",
157 state: "failure",
158 createdAt: newer,
159 updatedAt: newer,
160 }),
161 ];
162 const names = await passingCheckNames(REPO_ID, SHA);
163 expect(names).not.toContain("ci/regressed-ctx");
164 });
165});
Addedsrc/__tests__/pr-workflow-sync.test.ts+128−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/pr-workflow-sync.ts — the fix for `on: pull_request`
3 * workflow triggers being silently dead. Before this, `enqueueRun()` was
4 * never called with `event: "pull_request"` anywhere in the codebase, so a
5 * workflow declaring `on: pull_request` was parsed, stored in the
6 * `workflows` table (by push-workflow-sync.ts), and never actually run.
7 *
8 * Both the workflow lookup and enqueueRun are passed in via the injectable
9 * `deps` param (same DI rationale as codeowners.test.ts's
10 * requiredOwnersApproved tests) — no mock.module() on ../db or
11 * ../lib/workflow-runner, both of which are imported by dozens of unrelated
12 * test files and would leak a global mock across the whole `bun test` run.
13 */
14
15import { beforeEach, describe, expect, it } from "bun:test";
16import { enqueuePullRequestWorkflows } from "../lib/pr-workflow-sync";
17
18let _workflowRows: Array<{ id: string; onEvents: string }> = [];
19let _selectShouldThrow = false;
20
21let _enqueueCalls: any[] = [];
22let _enqueueShouldThrow = false;
23
24const fakeDeps = {
25 loadWorkflows: async (_repositoryId: string) => {
26 if (_selectShouldThrow) throw new Error("db boom");
27 return _workflowRows;
28 },
29 enqueueRun: async (opts: any) => {
30 if (_enqueueShouldThrow) throw new Error("enqueue boom");
31 _enqueueCalls.push(opts);
32 return "run-id-1";
33 },
34} as any;
35
36beforeEach(() => {
37 _workflowRows = [];
38 _selectShouldThrow = false;
39 _enqueueCalls = [];
40 _enqueueShouldThrow = false;
41});
42
43function baseOpts(overrides: Partial<Parameters<typeof enqueuePullRequestWorkflows>[0]> = {}) {
44 return {
45 repositoryId: "repo-1",
46 headBranch: "feature/foo",
47 headSha: "b".repeat(40),
48 triggeredBy: "user-1",
49 ...overrides,
50 };
51}
52
53describe("enqueuePullRequestWorkflows", () => {
54 it("returns zero enqueued when the repo has no workflows", async () => {
55 _workflowRows = [];
56 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
57 expect(result).toEqual({ enqueued: 0, errors: [] });
58 expect(_enqueueCalls).toHaveLength(0);
59 });
60
61 it("enqueues a workflow whose on: includes pull_request", async () => {
62 _workflowRows = [{ id: "wf-1", onEvents: JSON.stringify(["pull_request"]) }];
63 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
64
65 expect(result.enqueued).toBe(1);
66 expect(result.errors).toEqual([]);
67 expect(_enqueueCalls[0]).toMatchObject({
68 workflowId: "wf-1",
69 repositoryId: "repo-1",
70 event: "pull_request",
71 ref: "feature/foo",
72 commitSha: "b".repeat(40),
73 triggeredBy: "user-1",
74 });
75 });
76
77 it("does NOT enqueue a workflow with only an on:push trigger", async () => {
78 _workflowRows = [{ id: "wf-2", onEvents: JSON.stringify(["push"]) }];
79 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
80
81 expect(result.enqueued).toBe(0);
82 expect(_enqueueCalls).toHaveLength(0);
83 });
84
85 it("enqueues only the matching workflow out of several", async () => {
86 _workflowRows = [
87 { id: "wf-push", onEvents: JSON.stringify(["push"]) },
88 { id: "wf-pr", onEvents: JSON.stringify(["push", "pull_request"]) },
89 { id: "wf-dispatch", onEvents: JSON.stringify(["workflow_dispatch"]) },
90 ];
91 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
92
93 expect(result.enqueued).toBe(1);
94 expect(_enqueueCalls[0].workflowId).toBe("wf-pr");
95 });
96
97 it("records a per-row error but keeps going when onEvents is malformed JSON", async () => {
98 _workflowRows = [
99 { id: "wf-broken", onEvents: "not json" },
100 { id: "wf-good", onEvents: JSON.stringify(["pull_request"]) },
101 ];
102 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
103
104 expect(result.enqueued).toBe(1);
105 expect(_enqueueCalls[0].workflowId).toBe("wf-good");
106 expect(result.errors.some((e) => e.includes("wf-broken"))).toBe(true);
107 });
108
109 it("records an error but does not throw when enqueueRun fails", async () => {
110 _workflowRows = [{ id: "wf-1", onEvents: JSON.stringify(["pull_request"]) }];
111 _enqueueShouldThrow = true;
112
113 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
114
115 expect(result.enqueued).toBe(0);
116 expect(result.errors.length).toBe(1);
117 expect(result.errors[0]).toContain("enqueue");
118 });
119
120 it("returns a query error but does not throw when the DB select fails", async () => {
121 _selectShouldThrow = true;
122 const result = await enqueuePullRequestWorkflows(baseOpts(), fakeDeps);
123
124 expect(result.enqueued).toBe(0);
125 expect(result.errors.length).toBe(1);
126 expect(result.errors[0]).toContain("query");
127 });
128});
Addedsrc/__tests__/push-policy-secret-scan.test.ts+128−0View fileUnifiedSplit
1/**
2 * Tests for the unconditional pre-receive secret scan added to
3 * src/lib/push-policy.ts (2026-07-16). Previously a critical secret
4 * (AWS/GitHub/Anthropic/Stripe key, PEM private key) pushed directly to a
5 * repo was only caught after the fact by the async post-receive scan in
6 * security-scan.ts — the secret was already in the object store by then.
7 * This scan runs inside the same pre-receive hook that already enforces
8 * ruleset pack-content rules, and rejects the push before objects are
9 * promoted.
10 *
11 * The hook's eval.js is a standalone, no-project-imports script (see
12 * buildEvalScript()'s doc comment) that runs via a real `bun run` subprocess
13 * inside a temp hooksPath dir — these tests exercise the actual generated
14 * script exactly as production does, rather than re-implementing its logic.
15 */
16
17import { describe, expect, it } from "bun:test";
18import { writeFileSync } from "fs";
19import { join } from "path";
20import {
21 hookSecretPatternTypes,
22 installPackInspectionHook,
23} from "../lib/push-policy";
24import { SECRET_PATTERNS } from "../lib/security-scan";
25
26describe("hook secret patterns stay in sync with security-scan.ts", () => {
27 it("covers every critical-severity pattern in SECRET_PATTERNS, and nothing else", () => {
28 const criticalTypes = SECRET_PATTERNS.filter((p) => p.severity === "critical").map((p) => p.type).sort();
29 const hookTypes = hookSecretPatternTypes().sort();
30 expect(hookTypes).toEqual(criticalTypes);
31 });
32});
33
34describe("pre-receive secret scan (real eval.js subprocess)", () => {
35 async function runEval(contentLines: string[]): Promise<{ active: string[]; stderr: string }> {
36 const hook = await installPackInspectionHook([], { secretScan: true });
37 expect(hook).not.toBeNull();
38 if (!hook) throw new Error("unreachable");
39 const dir = hook.env.GIT_CONFIG_VALUE_0;
40 try {
41 const commitsPath = join(dir, "commits.txt");
42 const sizesPath = join(dir, "sizes.txt");
43 const contentsPath = join(dir, "contents.txt");
44 writeFileSync(commitsPath, "");
45 writeFileSync(sizesPath, "");
46 writeFileSync(
47 contentsPath,
48 contentLines
49 .map((l) => l) // already "<path>\t<base64>" per line
50 .join("\n") + (contentLines.length ? "\n" : "")
51 );
52
53 const evalScriptPath = join(dir, "eval.js");
54 const rulesJsonPath = join(dir, "rules.json");
55 const proc = Bun.spawnSync([
56 "bun",
57 "run",
58 evalScriptPath,
59 "--",
60 rulesJsonPath,
61 commitsPath,
62 sizesPath,
63 contentsPath,
64 ]);
65 const stdout = new TextDecoder().decode(proc.stdout).trim();
66 const stderr = new TextDecoder().decode(proc.stderr);
67 const active = stdout
68 .split("\n")
69 .filter(Boolean)
70 .filter((line) => line.startsWith("active\t"))
71 .map((line) => line.slice("active\t".length));
72 return { active, stderr };
73 } finally {
74 await hook.cleanup();
75 }
76 }
77
78 function fileLine(path: string, content: string): string {
79 return `${path}\t${Buffer.from(content, "utf8").toString("base64")}`;
80 }
81
82 it("blocks a critical secret (AWS access key) in a normal source file", async () => {
83 const { active, stderr } = await runEval([
84 fileLine("src/config.ts", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"),
85 ]);
86 expect(stderr).toBe("");
87 expect(active.length).toBe(1);
88 expect(active[0]).toContain("AWS Access Key");
89 expect(active[0]).toContain("src/config.ts:1");
90 });
91
92 it("does not leak the secret value itself into the rejection message", async () => {
93 const { active } = await runEval([
94 fileLine("src/config.ts", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"),
95 ]);
96 expect(active[0]).not.toContain("AKIAABCDEFGH1234IJKL");
97 });
98
99 it("does not block an obvious placeholder value", async () => {
100 const { active } = await runEval([
101 fileLine("src/config.ts", "const AWS_KEY = 'AKIAEXAMPLE1234EXAMP'; // example only\n"),
102 ]);
103 expect(active.length).toBe(0);
104 });
105
106 it("does not scan skip-listed paths (build output, lockfiles)", async () => {
107 const { active } = await runEval([
108 fileLine("dist/bundle.js", "const AWS_KEY = 'AKIAABCDEFGH1234IJKL';\n"),
109 ]);
110 expect(active.length).toBe(0);
111 });
112
113 it("does not block a clean file with no secret patterns", async () => {
114 const { active } = await runEval([
115 fileLine("README.md", "# Hello\n\nJust a normal readme with no secrets.\n"),
116 ]);
117 expect(active.length).toBe(0);
118 });
119
120 it("scans multiple files and reports one violation per match", async () => {
121 const { active } = await runEval([
122 fileLine("a.ts", "AKIAABCDEFGH1234IJKL\n"),
123 fileLine("b.ts", "-----BEGIN RSA PRIVATE KEY-----\n"),
124 fileLine("c.ts", "nothing interesting here\n"),
125 ]);
126 expect(active.length).toBe(2);
127 });
128});
Modifiedsrc/lib/autorepair.ts+138−68View fileUnifiedSplit
1818 * 10. Dead code detection markers
1919 */
2020
21import { mkdtemp, rm } from "fs/promises";
22import { join } from "path";
23import { tmpdir } from "os";
2124import { getRepoPath, getDefaultBranch } from "../git/repository";
2225
2326export interface RepairResult {
3639async function exec(
3740 cmd: string[],
3841 cwd: string,
39 stdin?: string
40): Promise<{ stdout: string; exitCode: number }> {
42 stdin?: string,
43 extraEnv?: Record<string, string>
44): Promise<{ stdout: string; stderr: string; exitCode: number }> {
4145 const proc = Bun.spawn(cmd, {
4246 cwd,
4347 stdout: "pipe",
4953 GIT_AUTHOR_EMAIL: "bot@gluecron.com",
5054 GIT_COMMITTER_NAME: "gluecron[bot]",
5155 GIT_COMMITTER_EMAIL: "bot@gluecron.com",
56 ...extraEnv,
5257 },
5358 });
5459 if (stdin !== undefined && proc.stdin) {
5661 proc.stdin.end();
5762 }
5863 const stdout = await new Response(proc.stdout).text();
64 const stderr = await new Response(proc.stderr).text();
5965 const exitCode = await proc.exited;
60 return { stdout: stdout.trim(), exitCode };
66 return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
67}
68
69/** 40 lowercase hex chars — the shape of a real git object SHA. */
70function looksLikeSha(s: string): boolean {
71 return /^[0-9a-f]{40}$/.test(s);
6172}
6273
6374export async function autoRepair(
247258 return { repaired: false, repairs: [], commitSha: null };
248259 }
249260
250 // Build new tree with modifications
251 const { stdout: currentTree } = await exec(
252 ["git", "ls-tree", "-r", ref],
253 repoDir
254 );
255
256 const treeEntries = currentTree.split("\n").filter(Boolean);
257 const modifiedPaths = new Set(modifications.keys());
258 const newEntries: string[] = [];
261 // Build the new tree via a private, isolated index file rather than raw
262 // `git mktree`.
263 //
264 // INCIDENT (2026-07-16): the previous implementation fed `git ls-tree -r`
265 // output — full nested paths like "src/lib/foo.ts" — directly into
266 // `git mktree`. `git mktree` only ever builds a SINGLE tree level and
267 // fatally rejects any entry whose name contains a slash ("fatal: path
268 // src/lib/foo.ts contains slash", exit 128). Because none of the exec()
269 // calls in this function checked their exit code, that fatal error was
270 // silently swallowed: `newTreeSha` ended up built from only the handful
271 // of top-level entries that happened to parse before mktree aborted, and
272 // that near-empty tree was committed and force-written over the branch
273 // ref via `update-ref` — silently deleting the rest of the repository's
274 // tracked files while leaving an innocuous-looking "N automatic repairs"
275 // commit message with no indication anything else changed.
276 //
277 // `git read-tree` + `git update-index --cacheinfo` + `git write-tree`
278 // handle nested paths natively (this is what `git commit` itself uses
279 // internally) and every step below now checks its exit code — any
280 // unexpected failure aborts the repair with no commit made, rather than
281 // writing a partial/corrupt tree.
282 const tmpIndexDir = await mkdtemp(join(tmpdir(), "gluecron-repair-"));
283 const gitEnv = { GIT_INDEX_FILE: join(tmpIndexDir, "index") };
284 try {
285 const readTree = await exec(["git", "read-tree", ref], repoDir, undefined, gitEnv);
286 if (readTree.exitCode !== 0) {
287 console.error(
288 `[autorepair] ${owner}/${repo}@${ref}: git read-tree failed (exit ${readTree.exitCode}): ${readTree.stderr} — aborting, no commit made.`
289 );
290 return { repaired: false, repairs: [], commitSha: null };
291 }
259292
260 // Update existing entries
261 for (const entry of treeEntries) {
262 const match = entry.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
263 if (!match) continue;
264 const [, mode, type, sha, path] = match;
293 // Look up each modified path's existing mode (preserve the executable
294 // bit etc.); new files (e.g. a freshly-created .gitignore) default to
295 // a plain non-executable blob, matching the previous behaviour.
296 const modeByPath = new Map<string, string>();
297 for (const line of (await exec(["git", "ls-tree", "-r", ref], repoDir)).stdout.split("\n")) {
298 const match = line.match(/^(\d+) blob [0-9a-f]+\t(.+)$/);
299 if (match) modeByPath.set(match[2], match[1]);
300 }
265301
266 if (modifiedPaths.has(path)) {
267 const newContent = modifications.get(path)!;
268 const { stdout: newBlobSha } = await exec(
269 ["git", "hash-object", "-w", "--stdin"],
302 for (const [path, content] of modifications) {
303 const hashed = await exec(["git", "hash-object", "-w", "--stdin"], repoDir, content);
304 if (hashed.exitCode !== 0 || !looksLikeSha(hashed.stdout)) {
305 console.error(
306 `[autorepair] ${owner}/${repo}@${ref}: git hash-object failed for ${path} (exit ${hashed.exitCode}): ${hashed.stderr} — aborting, no commit made.`
307 );
308 return { repaired: false, repairs: [], commitSha: null };
309 }
310 const mode = modeByPath.get(path) ?? "100644";
311 const staged = await exec(
312 ["git", "update-index", "--add", "--cacheinfo", `${mode},${hashed.stdout},${path}`],
270313 repoDir,
271 newContent
314 undefined,
315 gitEnv
272316 );
273 newEntries.push(`${mode} blob ${newBlobSha}\t${path}`);
274 modifiedPaths.delete(path);
275 } else {
276 newEntries.push(entry);
317 if (staged.exitCode !== 0) {
318 console.error(
319 `[autorepair] ${owner}/${repo}@${ref}: git update-index failed for ${path} (exit ${staged.exitCode}): ${staged.stderr} — aborting, no commit made.`
320 );
321 return { repaired: false, repairs: [], commitSha: null };
322 }
277323 }
278 }
279
280 // Add new files (like .gitignore if it didn't exist)
281 for (const path of modifiedPaths) {
282 const content = modifications.get(path)!;
283 const { stdout: blobSha } = await exec(
284 ["git", "hash-object", "-w", "--stdin"],
285 repoDir,
286 content
287 );
288 newEntries.push(`100644 blob ${blobSha}\t${path}`);
289 }
290324
291 // Create new tree
292 const treeInput = newEntries.join("\n") + "\n";
293 const { stdout: newTreeSha } = await exec(
294 ["git", "mktree"],
295 repoDir,
296 treeInput
297 );
325 const writeTree = await exec(["git", "write-tree"], repoDir, undefined, gitEnv);
326 if (writeTree.exitCode !== 0 || !looksLikeSha(writeTree.stdout)) {
327 console.error(
328 `[autorepair] ${owner}/${repo}@${ref}: git write-tree failed (exit ${writeTree.exitCode}): ${writeTree.stderr} — aborting, no commit made.`
329 );
330 return { repaired: false, repairs: [], commitSha: null };
331 }
332 const newTreeSha = writeTree.stdout;
298333
299 // Get parent
300 const { stdout: parentSha } = await exec(
301 ["git", "rev-parse", ref],
302 repoDir
303 );
334 // Get parent
335 const parent = await exec(["git", "rev-parse", ref], repoDir);
336 if (parent.exitCode !== 0 || !looksLikeSha(parent.stdout)) {
337 console.error(
338 `[autorepair] ${owner}/${repo}@${ref}: git rev-parse failed (exit ${parent.exitCode}): ${parent.stderr} — aborting, no commit made.`
339 );
340 return { repaired: false, repairs: [], commitSha: null };
341 }
342 const parentSha = parent.stdout;
343
344 // Sanity check: the new tree must not be suspiciously smaller than the
345 // original — a last-resort guard against any future variant of this
346 // same class of bug silently truncating the repository. `files` was
347 // already fetched from the same ref at the top of this function, so
348 // this reuses it instead of spending another subprocess round-trip.
349 const originalFileCount = files.length;
350 const newFileCount = (await exec(["git", "ls-tree", "-r", "--name-only", newTreeSha], repoDir)).stdout
351 .split("\n")
352 .filter(Boolean).length;
353 if (newFileCount < originalFileCount) {
354 console.error(
355 `[autorepair] ${owner}/${repo}@${ref}: refusing to commit — new tree has ${newFileCount} files, original had ${originalFileCount}. This should never happen; aborting with no commit made.`
356 );
357 return { repaired: false, repairs: [], commitSha: null };
358 }
304359
305 // Create commit
306 const repairSummary = repairs
307 .map((r) => `- ${r.file}: ${r.description}`)
308 .join("\n");
360 // Create commit
361 const repairSummary = repairs
362 .map((r) => `- ${r.file}: ${r.description}`)
363 .join("\n");
309364
310 const commitMsg = `fix: auto-repair by gluecron\n\n${repairs.length} automatic repair${repairs.length > 1 ? "s" : ""}:\n${repairSummary}\n\nThis commit was created automatically by gluecron's repair engine.`;
365 const commitMsg = `fix: auto-repair by gluecron\n\n${repairs.length} automatic repair${repairs.length > 1 ? "s" : ""}:\n${repairSummary}\n\nThis commit was created automatically by gluecron's repair engine.`;
311366
312 const { stdout: commitSha } = await exec(
313 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", commitMsg],
314 repoDir
315 );
367 const commit = await exec(["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", commitMsg], repoDir);
368 if (commit.exitCode !== 0 || !looksLikeSha(commit.stdout)) {
369 console.error(
370 `[autorepair] ${owner}/${repo}@${ref}: git commit-tree failed (exit ${commit.exitCode}): ${commit.stderr} — aborting, no commit made.`
371 );
372 return { repaired: false, repairs: [], commitSha: null };
373 }
374 const commitSha = commit.stdout;
316375
317 // Update ref
318 await exec(
319 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
320 repoDir
321 );
376 // Update ref — compare-and-swap against the parent we just read, so a
377 // concurrent push landing between rev-parse and here is rejected by git
378 // rather than silently overwritten.
379 const updateRef = await exec(
380 ["git", "update-ref", `refs/heads/${ref}`, commitSha, parentSha],
381 repoDir
382 );
383 if (updateRef.exitCode !== 0) {
384 console.error(
385 `[autorepair] ${owner}/${repo}@${ref}: git update-ref failed (exit ${updateRef.exitCode}): ${updateRef.stderr} — repair commit ${commitSha.slice(0, 7)} was created but NOT applied to the branch.`
386 );
387 return { repaired: false, repairs: [], commitSha: null };
388 }
322389
323 console.log(
324 `[autorepair] ${owner}/${repo}@${ref}: ${repairs.length} repairs committed as ${commitSha.slice(0, 7)}`
325 );
390 console.log(
391 `[autorepair] ${owner}/${repo}@${ref}: ${repairs.length} repairs committed as ${commitSha.slice(0, 7)}`
392 );
326393
327 return { repaired: true, repairs, commitSha };
394 return { repaired: true, repairs, commitSha };
395 } finally {
396 await rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {});
397 }
328398}
Modifiedsrc/lib/branch-protection.ts+35−2View fileUnifiedSplit
2828} from "../db/schema";
2929import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
3030import { matchGlob } from "./environments";
31import { listStatuses } from "./commit-statuses";
3132
3233export interface ProtectionEvalContext {
3334 aiApproved: boolean;
217218 * - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
218219 * gateName), or
219220 * - a `workflow_runs` row where `status = 'success'` (matched by workflow
220 * name, joined through the workflows table).
221 * name, joined through the workflows table), or
222 * - a `commit_statuses` row (external CI signal, see commit-statuses.ts)
223 * whose LATEST row (by createdAt) for a given context has
224 * `state = 'success'` (matched by context).
221225 *
222 * Passing names are aggregated across the last N rows to survive re-runs.
226 * Passing names for gate_runs/workflow_runs are aggregated across the last N
227 * rows to survive re-runs (any passing row in the window counts). Commit
228 * statuses are different: only the single most-recent row per context counts
229 * — a stale "success" followed by a newer "failure" must NOT count as
230 * passing, so we explicitly resolve "latest per context" rather than OR-ing
231 * across the window.
223232 */
224233export async function passingCheckNames(
225234 repositoryId: string,
275284 // ignore
276285 }
277286
287 try {
288 if (commitSha) {
289 const sRows = await listStatuses(repositoryId, commitSha);
290 // Resolve "latest per context" — a re-post to the same context must
291 // supersede an older one, in either direction (success->failure or
292 // failure->success), so we can't just OR across the window like the
293 // gate_runs/workflow_runs blocks above.
294 const latestByContext = new Map<string, (typeof sRows)[number]>();
295 for (const r of sRows) {
296 const prev = latestByContext.get(r.context);
297 if (!prev || prev.createdAt < r.createdAt) {
298 latestByContext.set(r.context, r);
299 }
300 }
301 for (const r of latestByContext.values()) {
302 if (r.state === "success") {
303 names.add(r.context);
304 }
305 }
306 }
307 } catch {
308 // ignore
309 }
310
278311 return Array.from(names);
279312}
Modifiedsrc/lib/codeowners.ts+89−1View fileUnifiedSplit
1515 * current membership at review-request time.
1616 */
1717
18import { and, eq } from "drizzle-orm";
18import { and, desc, eq } from "drizzle-orm";
1919import { db } from "../db";
2020import {
2121 codeOwners,
2323 teams,
2424 teamMembers,
2525 users,
26 prReviews,
2627} from "../db/schema";
2728import { getBlob } from "../git/repository";
2829
237238
238239 return [];
239240}
241
242/**
243 * Default `loadApprovedUsernames` dependency for `requiredOwnersApproved` —
244 * returns the set of usernames whose *latest* non-"commented" `pr_reviews`
245 * row for this PR is `state === 'approved'`. Same dedup rule as
246 * `countHumanApprovals()` in branch-protection.ts (most recent review per
247 * reviewer wins; a stale "approved" followed by "changes_requested" does
248 * NOT count as approved).
249 */
250async function loadApprovedUsernames(pullRequestId: string): Promise<Set<string>> {
251 const rows = await db
252 .select({ state: prReviews.state, username: users.username })
253 .from(prReviews)
254 .innerJoin(users, eq(users.id, prReviews.reviewerId))
255 .where(
256 and(eq(prReviews.pullRequestId, pullRequestId), eq(prReviews.isAi, false))
257 )
258 .orderBy(desc(prReviews.createdAt));
259
260 const latestByUsername = new Map<string, string>();
261 for (const r of rows) {
262 if (r.state !== "commented" && !latestByUsername.has(r.username)) {
263 latestByUsername.set(r.username, r.state);
264 }
265 }
266 const approved = new Set<string>();
267 for (const [username, state] of latestByUsername) {
268 if (state === "approved") approved.add(username);
269 }
270 return approved;
271}
272
273/**
274 * Merge-time CODEOWNERS enforcement.
275 *
276 * `reviewersForChangedFiles()` above only ever *requests* CODEOWNERS
277 * reviewers at PR-open time — nothing previously checked whether they
278 * actually approved before a merge was allowed. This is the missing check:
279 * given a PR's changed files, resolve the CODEOWNERS-required owners (same
280 * pattern-matching + team-expansion helpers as auto-assign) and report which
281 * of them have NOT approved via `pr_reviews`.
282 *
283 * "Satisfied" (no missing owners) also covers the no-CODEOWNERS-file case
284 * and the no-owner-matched-these-files case — this function only enforces
285 * what CODEOWNERS actually touches, it never invents a requirement.
286 *
287 * Fails open (`satisfied: true`) on any internal error (git fetch, parse, or
288 * DB failure) — a bug in this check must never hard-block every merge
289 * platform-wide. Matches the `[codeowners] auto-assign failed` fail-soft
290 * style used at PR-creation time.
291 *
292 * `deps` is injectable for tests, mirroring the pattern in
293 * push-workflow-sync.ts — avoids a global `mock.module()` on `../git/repository`
294 * or `../db`, both of which are imported by dozens of unrelated test files.
295 */
296export async function requiredOwnersApproved(
297 owner: string,
298 repo: string,
299 codeownersBranch: string,
300 pullRequestId: string,
301 changedFilePaths: string[],
302 deps: {
303 getCodeownersForRepo: typeof getCodeownersForRepo;
304 loadApprovedUsernames: (pullRequestId: string) => Promise<Set<string>>;
305 } = { getCodeownersForRepo, loadApprovedUsernames }
306): Promise<{ satisfied: boolean; missingOwners: string[] }> {
307 try {
308 const rules = await deps.getCodeownersForRepo(owner, repo, codeownersBranch);
309 if (rules.length === 0) return { satisfied: true, missingOwners: [] };
310
311 const tokens = matchOwners(changedFilePaths, rules);
312 if (tokens.length === 0) return { satisfied: true, missingOwners: [] };
313
314 const requiredOwners = await expandOwnerTokens(tokens);
315 if (requiredOwners.length === 0) return { satisfied: true, missingOwners: [] };
316
317 const approved = await deps.loadApprovedUsernames(pullRequestId);
318 const missingOwners = requiredOwners.filter((u) => !approved.has(u));
319 return { satisfied: missingOwners.length === 0, missingOwners };
320 } catch (err) {
321 console.warn(
322 "[codeowners] requiredOwnersApproved failed:",
323 err instanceof Error ? err.message : err
324 );
325 return { satisfied: true, missingOwners: [] };
326 }
327}
Modifiedsrc/lib/config.ts+11−0View fileUnifiedSplit
181181 get dependencyScanEnabled() {
182182 return process.env.DEPENDENCY_SCAN_ENABLED === "1";
183183 },
184 /**
185 * Pre-receive secret scan (src/lib/push-policy.ts) — on by default. Blocks
186 * a push synchronously when a critical-severity secret pattern (AWS/GitHub/
187 * Anthropic/Stripe keys, PEM private keys) is found in the pushed pack.
188 * Set to "1" to disable — an emergency kill switch in case the scan itself
189 * misbehaves and starts wedging legitimate pushes platform-wide; the
190 * existing post-receive async scan (security-scan.ts) still runs either way.
191 */
192 get secretScanOnPushDisabled() {
193 return process.env.SECRET_SCAN_ON_PUSH_DISABLED === "1";
194 },
184195};
Modifiedsrc/lib/mcp-tools.ts+39−0View fileUnifiedSplit
4343} from "./branch-protection";
4444import { mergeWithAutoResolve } from "./merge-resolver";
4545import { isAiReviewEnabled } from "./ai-review";
46import { requiredOwnersApproved } from "./codeowners";
4647import {
4748 computePrRiskForPullRequest,
4849 getCachedPrRisk,
12631264 },
12641265 required.map((r) => r.checkName)
12651266 );
1267
1268 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1269 // when the rule already requires human review at all (no new DB
1270 // column for this pass). Fail-open on any internal error: a bug here
1271 // must never hard-block every merge platform-wide. Mirrors the HTTP
1272 // merge path in routes/pulls.tsx.
1273 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1274 try {
1275 const codeownersDiffProc = Bun.spawn(
1276 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1277 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1278 );
1279 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1280 await codeownersDiffProc.exited;
1281 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1282 if (changedPaths.length > 0) {
1283 const { satisfied, missingOwners } = await requiredOwnersApproved(
1284 owner,
1285 repo,
1286 gate.defaultBranch,
1287 pr.id,
1288 changedPaths
1289 );
1290 if (!satisfied) {
1291 decision.allowed = false;
1292 decision.reasons.push(
1293 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1294 );
1295 }
1296 }
1297 } catch (err) {
1298 console.warn(
1299 "[codeowners] merge enforcement failed:",
1300 err instanceof Error ? err.message : err
1301 );
1302 }
1303 }
1304
12661305 if (!decision.allowed) {
12671306 return { merged: false, reason: decision.reasons.join(" ") };
12681307 }
Addedsrc/lib/pr-workflow-sync.ts+107−0View fileUnifiedSplit
1/**
2 * Enqueue `on: pull_request` workflow runs when a PR is opened.
3 *
4 * `.gluecron/workflows/*.yml` files with `on: pull_request` (or the
5 * `[pull_request]` / `{pull_request: {...}}` shapes — all normalised by
6 * `normaliseOn()` in workflow-parser.ts) were parsed and stored into the
7 * `workflows` table on every push (see push-workflow-sync.ts), but nothing
8 * ever called `enqueueRun()` with `event: "pull_request"` — every call site
9 * only ever passed "push", "workflow_dispatch", "manual", or "schedule". A
10 * workflow declaring `on: pull_request` was silently dead: parsed, stored,
11 * never run, no error anywhere. This module is the missing wiring, called
12 * from the PR-creation handler (src/routes/pulls.tsx).
13 *
14 * Workflows are already synced into the `workflows` table by push (the
15 * `.gluecron/workflows/` dir only changes via push), so unlike
16 * push-workflow-sync.ts this module does NOT re-read the git tree — it just
17 * queries the existing table for non-disabled workflows whose `on:` events
18 * include `pull_request`.
19 *
20 * Scope: PR **open** only, mirroring the task that introduced this file.
21 * GitHub also fires `on: pull_request` for `synchronize` (new commits pushed
22 * to an open PR's head branch) and `closed` — those are NOT wired here; a
23 * follow-up would hook the same query into the PR-synchronize and close
24 * paths.
25 *
26 * Best-effort: a broken row or a failing enqueue is recorded in `errors`
27 * and skipped, never thrown — a bug here must not break PR creation.
28 */
29
30import { and, eq } from "drizzle-orm";
31import { db } from "../db";
32import { workflows } from "../db/schema";
33import { enqueueRun as realEnqueueRun } from "./workflow-runner";
34
35export interface PrWorkflowSyncResult {
36 enqueued: number;
37 errors: string[];
38}
39
40async function loadPullRequestEligibleWorkflows(
41 repositoryId: string
42): Promise<Array<{ id: string; onEvents: string }>> {
43 return db
44 .select({ id: workflows.id, onEvents: workflows.onEvents })
45 .from(workflows)
46 .where(
47 and(eq(workflows.repositoryId, repositoryId), eq(workflows.disabled, false))
48 );
49}
50
51export async function enqueuePullRequestWorkflows(
52 opts: {
53 repositoryId: string;
54 headBranch: string;
55 headSha: string;
56 triggeredBy?: string | null;
57 },
58 // Injectable for tests — avoids mock.module() on ../db or ./workflow-runner,
59 // both of which are imported by dozens of unrelated test files and would
60 // leak a global mock across the whole test run (same rationale as
61 // push-workflow-sync.ts / codeowners.ts's requiredOwnersApproved).
62 deps: {
63 enqueueRun: typeof realEnqueueRun;
64 loadWorkflows: typeof loadPullRequestEligibleWorkflows;
65 } = { enqueueRun: realEnqueueRun, loadWorkflows: loadPullRequestEligibleWorkflows }
66): Promise<PrWorkflowSyncResult> {
67 const result: PrWorkflowSyncResult = { enqueued: 0, errors: [] };
68
69 let rows: Array<{ id: string; onEvents: string }>;
70 try {
71 rows = await deps.loadWorkflows(opts.repositoryId);
72 } catch (err) {
73 result.errors.push(
74 `query: ${err instanceof Error ? err.message : String(err)}`
75 );
76 return result;
77 }
78
79 for (const row of rows) {
80 let onEvents: unknown;
81 try {
82 onEvents = JSON.parse(row.onEvents);
83 } catch {
84 result.errors.push(`${row.id}: malformed onEvents JSON`);
85 continue;
86 }
87 if (!Array.isArray(onEvents) || !onEvents.includes("pull_request")) continue;
88
89 try {
90 await deps.enqueueRun({
91 workflowId: row.id,
92 repositoryId: opts.repositoryId,
93 event: "pull_request",
94 ref: opts.headBranch,
95 commitSha: opts.headSha,
96 triggeredBy: opts.triggeredBy ?? null,
97 });
98 result.enqueued += 1;
99 } catch (err) {
100 result.errors.push(
101 `enqueue ${row.id}: ${err instanceof Error ? err.message : String(err)}`
102 );
103 }
104 }
105
106 return result;
107}
Modifiedsrc/lib/push-policy.ts+98−13View fileUnifiedSplit
2020 * PushContext is wired to false unless
2121 * a smarter caller fills it in.
2222 *
23 * 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 *
2332 * Pure helpers + DB callers; never throws into the request path. On any
2433 * unexpected failure we return {allowed:true} (fail-open) to preserve the
2534 * existing no-policy behaviour rather than wedging legitimate pushes when
3948 parseParams,
4049 type PushContext,
4150} from "./rulesets";
51import { config } from "./config";
4252import type { RulesetRule, RepoRuleset } from "../db/schema";
4353
4454export type RefUpdate = {
6979
7080type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };
7181
82/**
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)$";
109const HOOK_PLACEHOLDER_RE = "example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme";
110
72111/**
73112 * 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>")
113 * per ref. Receives four file-path arguments:
114 * argv[2] = rules.json path
115 * argv[3] = commits file (lines: "<sha> <subject>")
116 * argv[4] = sizes file (lines: "<bytes> <path>")
117 * argv[5] = contents file (lines: "<path>\t<base64 content>", size-capped)
78118 *
79119 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
80120 * Exits 0 always — the shell hook interprets the output.
134174 " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');",
135175 " }",
136176 "}",
177 // Unconditional secret scan — independent of rules.json / rulesets.
178 // Only runs when argv[5] (contents file) was provided by the caller.
179 `const SECRET_PATTERNS = ${JSON.stringify(HOOK_SECRET_PATTERNS)}.map(p => ({ type: p.type, re: new RegExp(p.source, p.flags) }));`,
180 `const SKIP_RE = new RegExp(${JSON.stringify(HOOK_SKIP_PATH_RE)}, 'i');`,
181 `const PLACEHOLDER_RE = new RegExp(${JSON.stringify(HOOK_PLACEHOLDER_RE)}, 'i');`,
182 "if (process.argv[5]) {",
183 " let contentLines = [];",
184 " try { contentLines = readFileSync(process.argv[5], 'utf8').split('\\n').filter(Boolean); } catch { contentLines = []; }",
185 " for (const line of contentLines) {",
186 " const tab = line.indexOf('\\t');",
187 " if (tab < 0) continue;",
188 " const fp = line.slice(0, tab);",
189 " const b64 = line.slice(tab + 1);",
190 " if (SKIP_RE.test(fp)) continue;",
191 " let text;",
192 " try { text = Buffer.from(b64, 'base64').toString('utf8'); } catch { continue; }",
193 " const fileLines = text.split('\\n');",
194 " for (let i = 0; i < fileLines.length; i++) {",
195 " const l = fileLines[i];",
196 " if (PLACEHOLDER_RE.test(l)) continue;",
197 " for (const p of SECRET_PATTERNS) {",
198 " if (p.re.test(l)) {",
199 " 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.');",
200 " break;",
201 " }",
202 " }",
203 " }",
204 " }",
205 "}",
137206 "process.stdout.write(out.join('\\n'));",
138207 ].join("\n") + "\n";
139208}
166235 ` COMMITS_TMP=$(mktemp)`,
167236 ` SIZES_TMP=$(mktemp)`,
168237 ` PATHS_TMP=$(mktemp)`,
238 ` CONTENTS_TMP=$(mktemp)`,
169239 ` git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`,
170240 ` git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
171241 "",
172 // Build sizes file: "<bytes> <path>" per changed file.
242 // Build sizes file: "<bytes> <path>" per changed file. Also dump
243 // base64 content (capped at 256KB/file) for the secret scan below —
244 // a side write to CONTENTS_TMP, independent of this loop's own
245 // stdout redirect into SIZES_TMP.
173246 ` while IFS= read -r FP; do`,
174247 ` [ -z "${D}FP" ] && continue`,
175248 ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
176249 ` SZ=0`,
177250 ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
178251 ` printf '%s %s\\n' "${D}SZ" "${D}FP"`,
252 ` if [ -n "${D}BLOB" ] && [ "${D}SZ" -gt 0 ] && [ "${D}SZ" -le 262144 ]; then`,
253 ` 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')`,
254 ` [ -n "${D}B64" ] && printf '%s\\t%s\\n' "${D}FP" "${D}B64" >> "${D}CONTENTS_TMP"`,
255 ` fi`,
179256 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
180257 "",
181 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" 2>/dev/null || true)`,
258 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP" 2>/dev/null || true)`,
182259 "",
183260 // Each output line is "<enforcement>\t<message>"; split on tab.
184261 ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
187264 ` [ "${D}ENFORCE" = "active" ] && FAILED=1`,
188265 ` done <<< "${D}RESULT"`,
189266 "",
190 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP"`,
267 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP"`,
191268 `done`,
192269 "",
193270 `exit ${D}FAILED`,
205282 * the push.
206283 */
207284export async function installPackInspectionHook(
208 rulesets: ActiveRuleset[]
285 rulesets: ActiveRuleset[],
286 opts: { secretScan?: boolean } = {}
209287): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
288 const secretScan = opts.secretScan !== false;
289
210290 // Collect pack-content rules from non-disabled rulesets.
211291 type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
212292 const commitMsgRules: RuleEntry[] = [];
224304 }
225305 }
226306
227 // No pack-content rules → skip hook installation entirely.
228 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length) {
307 // No pack-content rules AND secret scan disabled → skip hook entirely.
308 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) {
229309 return null;
230310 }
231311
372452 try {
373453 rulesets = await listRulesetsForRepo(repositoryId);
374454 } catch {
375 return null;
455 rulesets = [];
376456 }
377 if (!rulesets.length) return null;
378 return installPackInspectionHook(rulesets);
457 // Always install — even with zero rulesets — so the unconditional secret
458 // scan still runs. Only a listRulesetsForRepo throw plus secret-scan being
459 // disabled entirely skips the hook (handled by installPackInspectionHook's
460 // own early-return when both inputs are empty).
461 return installPackInspectionHook(rulesets, {
462 secretScan: !config.secretScanOnPushDisabled,
463 });
379464}
380465
381466/** Build a human-readable error body for the 403 response. */
Modifiedsrc/routes/git.ts+3−1View fileUnifiedSplit
212212 }
213213
214214 // Pack-content inspection: install a pre-receive hook that enforces
215 // commit_message_pattern, blocked_file_paths, and max_file_size rules.
215 // commit_message_pattern, blocked_file_paths, and max_file_size rules,
216 // plus an unconditional critical-secret scan (2026-07-16 — previously
217 // secrets were only caught after the fact by the async post-receive scan).
216218 // git-receive-pack runs the hook before promoting quarantined objects, so
217219 // a hook exit-1 leaves the repo unchanged. We clean up the temp dir
218220 // unconditionally after serviceRpc returns.
Modifiedsrc/routes/pulls.tsx+62−0View fileUnifiedSplit
133133import {
134134 getCodeownersForRepo,
135135 reviewersForChangedFiles,
136 requiredOwnersApproved,
136137} from "../lib/codeowners";
138import { enqueuePullRequestWorkflows } from "../lib/pr-workflow-sync";
137139import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
138140import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
139141import {
39243926 }
39253927 })();
39263928
3929 // `on: pull_request` workflow trigger — workflows are already synced
3930 // into the `workflows` table on push (push-workflow-sync.ts); PR-open
3931 // just needs to enqueue runs for the ones whose `on:` includes
3932 // `pull_request`. Fire-and-forget; must never block PR creation. Scoped
3933 // to PR open only — see pr-workflow-sync.ts header for why synchronize/
3934 // close aren't wired here yet.
3935 resolveRef(ownerName, repoName, headBranch)
3936 .then((headSha) => {
3937 if (!headSha) return;
3938 return enqueuePullRequestWorkflows({
3939 repositoryId: resolved.repo.id,
3940 headBranch,
3941 headSha,
3942 triggeredBy: user.id,
3943 });
3944 })
3945 .catch((err) =>
3946 console.warn(
3947 "[pr-workflow-sync] enqueue failed:",
3948 err instanceof Error ? err.message : err
3949 )
3950 );
3951
39273952 // Skip AI review on drafts — it runs again when the PR is marked ready.
39283953 if (!isDraft && isAiReviewEnabled()) {
39293954 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
62976322 },
62986323 required.map((r) => r.checkName)
62996324 );
6325
6326 // CODEOWNERS enforcement — additive to evaluateProtection(), only
6327 // when the rule already requires human review at all (no new DB
6328 // column for this pass). Fail-open on any internal error: a bug here
6329 // must never hard-block every merge platform-wide.
6330 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
6331 try {
6332 const codeownersDiffProc = Bun.spawn(
6333 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
6334 { cwd: getRepoPath(ownerName, repoName), stdout: "pipe", stderr: "pipe" }
6335 );
6336 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
6337 await codeownersDiffProc.exited;
6338 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
6339 if (changedPaths.length > 0) {
6340 const { satisfied, missingOwners } = await requiredOwnersApproved(
6341 ownerName,
6342 repoName,
6343 resolved.repo.defaultBranch,
6344 pr.id,
6345 changedPaths
6346 );
6347 if (!satisfied) {
6348 decision.allowed = false;
6349 decision.reasons.push(
6350 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
6351 );
6352 }
6353 }
6354 } catch (err) {
6355 console.warn(
6356 "[codeowners] merge enforcement failed:",
6357 err instanceof Error ? err.message : err
6358 );
6359 }
6360 }
6361
63006362 if (!decision.allowed) {
63016363 return c.redirect(
63026364 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
63036365