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

fix(security): close 5 critical holes — private-repo auth, push auth, runner secret leak, markdown XSS, admin endpoints

fix(security): close 5 critical holes — private-repo auth, push auth, runner secret leak, markdown XSS, admin endpoints

Audit (2026-07-07) found five exploitable CRITICAL issues; this batch closes all five.

- git.ts: enforce read access on clone/fetch/info-refs/HEAD and write access on
  push (git-receive-pack). Private repos previously world-readable and any repo
  world-writable (auth failed open). Public repos stay anonymous; PAT-in-URL works.
- web.tsx: add assertRepoReadable gate on 12 repo-content browse routes (tree,
  blob, raw, blame, commits, commit, branches, tags, search, overview, workspace,
  memory). Private repos now 404 for unauthorized viewers.
- workflow-runner.ts: stop inheriting full process.env into user CI steps; pass a
  minimal env allowlist with a secret denylist backstop. Closes platform-secret
  exfiltration (DATABASE_URL, *_KEY, PAT, ANTHROPIC_API_KEY, etc). User-declared
  ${{ secrets }} unaffected (text-substituted, not env-passed).
- markdown.ts: sanitize rendered HTML via sanitize-html (GitHub-like allowlist);
  strips <script>/<iframe>/on*/javascript: while preserving syntax highlighting.
  Closes ~30 stored-XSS sinks (README/issues/PR/wiki/discussions). +14 regression tests.
- admin-command.tsx: require site-admin on /admin/command/sandbox/{start,stop,list}
  (were unauthenticated — anon process spawn/kill). Bounded port allocator.

Full test suite: 23 fail before and after (identical pre-existing set); +14 new
passing tests. No new failures introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccanty labs committed on July 7, 2026Parent: 54f2e2a
8 files changed+588135bb52fafb66705b1da16db537d46e1b38eda13fe
8 changed files+588−13
Modifiedbun.lock+37−0View fileUnifiedSplit
1313 "hono": "^4.12.18",
1414 "marked": "^18.0.3",
1515 "postgres": "^3.4.9",
16 "sanitize-html": "^2.13.0",
1617 "ssh2": "^1.16.0",
1718 },
1819 "devDependencies": {
147148
148149 "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
149150
151 "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
152
153 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
154
155 "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
156
157 "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
158
159 "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
160
161 "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
162
150163 "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
151164
152165 "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
153166
167 "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
168
154169 "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
155170
171 "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
172
156173 "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
157174
158175 "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
163180
164181 "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="],
165182
183 "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
184
185 "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
186
166187 "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
167188
189 "launder": ["launder@1.7.1", "", { "dependencies": { "dayjs": "^1.11.7" } }, "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw=="],
190
168191 "marked": ["marked@18.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA=="],
169192
170193 "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
171194
195 "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
196
197 "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
198
199 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
200
172201 "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
173202
174203 "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
175204
205 "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
206
176207 "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
177208
178209 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
185216
186217 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
187218
219 "sanitize-html": ["sanitize-html@2.17.5", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg=="],
220
188221 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
189222
223 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
224
190225 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
191226
192227 "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
209244
210245 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
211246
247 "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
248
212249 "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
213250
214251 "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
Modifiedpackage.json+1−0View fileUnifiedSplit
3030 "hono": "^4.12.18",
3131 "marked": "^18.0.3",
3232 "postgres": "^3.4.9",
33 "sanitize-html": "^2.13.0",
3334 "ssh2": "^1.16.0"
3435 },
3536 "optionalDependencies": {
Addedsrc/__tests__/markdown-xss.test.ts+105−0View fileUnifiedSplit
1/**
2 * Security regression — stored XSS in markdown rendering.
3 *
4 * `renderMarkdown()` feeds untrusted markdown (READMEs, issues, PR comments,
5 * wikis, discussions, releases) into ~30 `dangerouslySetInnerHTML` sinks.
6 * These tests lock in that the final `sanitize-html` stage strips executable
7 * payloads while preserving normal markdown + our syntax-highlighted code
8 * blocks. Do not weaken these assertions without a security review.
9 */
10
11import { describe, it, expect } from "bun:test";
12import { renderMarkdown } from "../lib/markdown";
13
14describe("renderMarkdown — XSS sanitization", () => {
15 it("strips raw <script> tags and their payload", () => {
16 const out = renderMarkdown("<script>alert(1)</script>");
17 expect(out).not.toContain("<script");
18 expect(out).not.toContain("alert(1)");
19 });
20
21 it("strips img onerror handlers", () => {
22 const out = renderMarkdown("<img src=x onerror=alert(1)>");
23 expect(out).not.toContain("onerror");
24 expect(out.toLowerCase()).not.toContain("onerror=");
25 });
26
27 it("strips svg onload handlers", () => {
28 const out = renderMarkdown("<svg onload=alert(1)></svg>");
29 expect(out.toLowerCase()).not.toContain("onload");
30 expect(out).not.toContain("<svg");
31 });
32
33 it("strips iframes with javascript: src", () => {
34 const out = renderMarkdown('<iframe src="javascript:alert(1)"></iframe>');
35 expect(out).not.toContain("<iframe");
36 expect(out.toLowerCase()).not.toContain("javascript:");
37 });
38
39 it("neutralizes javascript: links in markdown link syntax", () => {
40 const out = renderMarkdown("[x](javascript:alert(1))");
41 // No executable javascript: URL should survive in an href.
42 expect(/href=["']?\s*javascript:/i.test(out)).toBe(false);
43 expect(out.toLowerCase()).not.toContain("javascript:alert");
44 });
45
46 it("strips inline event handlers on otherwise-allowed tags", () => {
47 const out = renderMarkdown('<a href="https://example.com" onclick="alert(1)">x</a>');
48 expect(out.toLowerCase()).not.toContain("onclick");
49 });
50
51 it("drops <style>, <object>, <embed>, <form> tags", () => {
52 const out = renderMarkdown(
53 "<style>body{display:none}</style><object data=x></object><embed src=x><form></form>",
54 );
55 expect(out).not.toContain("<style");
56 expect(out).not.toContain("<object");
57 expect(out).not.toContain("<embed");
58 expect(out).not.toContain("<form");
59 });
60
61 it("disallows data: URIs on images", () => {
62 const out = renderMarkdown("![x](data:text/html,<script>alert(1)</script>)");
63 expect(out.toLowerCase()).not.toContain("data:text/html");
64 });
65});
66
67describe("renderMarkdown — normal rendering preserved", () => {
68 it("renders headings", () => {
69 const out = renderMarkdown("# Hello");
70 expect(out).toContain("<h1");
71 expect(out).toContain("Hello");
72 });
73
74 it("renders bold text", () => {
75 const out = renderMarkdown("**bold**");
76 expect(out).toContain("<strong>bold</strong>");
77 });
78
79 it("renders safe http links", () => {
80 const out = renderMarkdown("[site](https://example.com)");
81 expect(out).toContain('href="https://example.com"');
82 });
83
84 it("renders images from http(s) sources", () => {
85 const out = renderMarkdown("![alt](https://example.com/a.png)");
86 expect(out).toContain('src="https://example.com/a.png"');
87 expect(out).toContain('alt="alt"');
88 });
89
90 it("preserves syntax-highlighted fenced code blocks", () => {
91 const out = renderMarkdown("```js\nconst x = 1;\n```");
92 expect(out).toContain("<pre>");
93 expect(out).toContain("<code");
94 // language class must survive so highlight.js CSS applies.
95 expect(out).toContain('class="language-js"');
96 // highlight.js emits hljs-* spans; the class attribute must survive.
97 expect(out).toContain("hljs-");
98 expect(out).toContain('class="hljs-');
99 });
100
101 it("renders inline code", () => {
102 const out = renderMarkdown("use `bun test` here");
103 expect(out).toContain("<code>bun test</code>");
104 });
105});
Modifiedsrc/lib/markdown.ts+57−2View fileUnifiedSplit
11/**
2 * Markdown rendering — server-side, sanitized.
2 * Markdown rendering — server-side.
3 *
4 * Output is HTML-sanitized via `sanitize-html` as the FINAL stage of
5 * `renderMarkdown()`. Raw `<script>`, `<iframe>`, `<style>`, `<object>`,
6 * `<embed>`, `<form>`, inline event handlers (`on*`), and `javascript:` /
7 * `data:` URLs are stripped, defeating stored XSS from untrusted markdown
8 * (READMEs, issues, PR comments, wikis, discussions, releases). The allowed
9 * tag/attribute subset is a GitHub-like safe set that preserves our
10 * syntax-highlighted code blocks (`<pre><code class="language-x">` plus
11 * highlight.js `<span class="hljs-...">`).
312 */
413
514import { Marked } from "marked";
15import sanitizeHtml from "sanitize-html";
616import { highlightCode } from "./highlight";
717
818const marked = new Marked({
3646 },
3747});
3848
49// GitHub-like safe subset. This is the security boundary for all rendered
50// markdown — do not widen without a security review.
51const sanitizeOptions: sanitizeHtml.IOptions = {
52 allowedTags: [
53 "h1", "h2", "h3", "h4", "h5", "h6",
54 "p", "a", "ul", "ol", "li", "blockquote",
55 "pre", "code", "span", "strong", "em", "b", "i", "del", "s",
56 "hr", "br",
57 "table", "thead", "tbody", "tr", "th", "td",
58 "img", "details", "summary", "sub", "sup", "kbd", "div", "input",
59 ],
60 allowedAttributes: {
61 a: ["href", "title", "rel"],
62 img: ["src", "alt", "title", "width", "height", "loading", "style"],
63 code: ["class"],
64 span: ["class"],
65 div: ["class"],
66 li: ["class"],
67 td: ["align", "colspan", "rowspan"],
68 th: ["align", "colspan", "rowspan"],
69 input: ["type", "checked", "disabled"],
70 },
71 // Links: http/https/mailto only. javascript:, vbscript:, file:, etc. stripped.
72 allowedSchemes: ["http", "https", "mailto"],
73 // Images: http/https only — data: URIs disallowed to block data:text/html payloads.
74 allowedSchemesByTag: {
75 img: ["http", "https"],
76 },
77 allowProtocolRelative: true,
78 // Restrict inline styles on <img> to a small harmless allowlist.
79 allowedStyles: {
80 img: {
81 "max-width": [/^\d+(?:px|%|em|rem)?$/],
82 width: [/^\d+(?:px|%|em|rem)?$/],
83 height: [/^\d+(?:px|%|em|rem)?$/],
84 },
85 },
86 // Drop disallowed tags entirely (and their attributes); keep safe text.
87 disallowedTagsMode: "discard",
88 // Never emit these, even as escaped text content of stripped tags.
89 nonTextTags: ["script", "style", "textarea", "noscript", "iframe", "object", "embed", "form"],
90};
91
3992export function renderMarkdown(source: string): string {
4093 const html = marked.parse(source);
4194 if (typeof html !== "string") return "";
42 return html;
95 // FINAL sanitization stage — strips any raw HTML that slipped through marked
96 // (raw <script>/<iframe>/on*= handlers, javascript:/data: URLs, etc.).
97 return sanitizeHtml(html, sanitizeOptions);
4398}
4499
45100function escapeHtml(str: string): string {
Modifiedsrc/lib/workflow-runner.ts+85−3View fileUnifiedSplit
9797 return value.slice(0, limit) + "\n[... truncated ...]";
9898}
9999
100// ---------------------------------------------------------------------------
101// SECURITY: child-process environment isolation (crown-jewel secret containment)
102// ---------------------------------------------------------------------------
103//
104// User-supplied workflow `run:` steps execute via `bash -c` on the production
105// host. They MUST NOT inherit the platform's `process.env`, or any user could
106// read DATABASE_URL, WORKFLOW_SECRETS_KEY, SERVER_TARGETS_KEY, ANTHROPIC_API_KEY,
107// GLUECRON_PAT, GOOGLE_OAUTH_CLIENT_SECRET, etc. — i.e. exfiltrate every secret
108// on the box.
109//
110// Instead the runner env is built from a curated allowlist of variables that
111// shells + common tooling need to function, plus the runner's own intended
112// injections (CI flags etc.). NOTE: user-declared `${{ secrets.NAME }}` are
113// injected by *text substitution* into the `run:` string upstream (see
114// substituteSecrets in runStep) — they are deliberately NOT passed through the
115// environment, so nothing sensitive needs to ride in here.
116
117/**
118 * Minimal set of environment variables copied from the host into a workflow
119 * step's subprocess. Each is only copied if actually present in process.env.
120 * These are non-sensitive shell/tooling essentials — nothing here reveals a
121 * platform secret.
122 */
123const RUNNER_ENV_ALLOWLIST: readonly string[] = [
124 // POSIX shell + locale + tooling essentials
125 "PATH",
126 "HOME",
127 "LANG",
128 "LC_ALL",
129 "TZ",
130 "TERM",
131 "SHELL",
132 "USER",
133 "HOSTNAME",
134 "TMPDIR",
135 // Windows equivalents (host may run the runner on win32 in dev)
136 "SYSTEMROOT",
137 "WINDIR",
138 "PATHEXT",
139 "COMSPEC",
140 "TEMP",
141 "TMP",
142 "USERPROFILE",
143];
144
145/**
146 * Hard denylist safety net. Even if a key somehow appears in the allowlist (or
147 * is added there by mistake later), never leak anything whose NAME looks like a
148 * platform credential. Applied to the copied host env only; the runner's own
149 * intentional injections (`extra`, incl. any user-declared secret the repo
150 * owner explicitly configured for their step) are exempt because they are
151 * meant to be visible to the step.
152 */
153const RUNNER_ENV_DENYLIST =
154 /(SECRET|TOKEN|_KEY$|_KEY_|PASSWORD|PAT|DATABASE_URL|ANTHROPIC|OPENAI|VOYAGE|STRIPE|GOOGLE_OAUTH|GITHUB_OAUTH|WORKFLOW_SECRETS|SERVER_TARGETS|SESSION|WEBHOOK_SECRET|EMERGENCY)/i;
155
156/**
157 * Build the environment for a workflow step subprocess.
158 *
159 * Starts from the curated {@link RUNNER_ENV_ALLOWLIST} (copying only keys that
160 * exist on the host and don't trip {@link RUNNER_ENV_DENYLIST}), then merges the
161 * runner's intended `extra` vars (CI flags, run id, and — in this codebase —
162 * nothing sensitive, since user secrets are text-substituted, not env-passed).
163 * `extra` wins on key collision and is exempt from the denylist by design.
164 */
165function buildRunnerEnv(
166 extra: Record<string, string> = {}
167): Record<string, string> {
168 const out: Record<string, string> = {};
169 for (const key of RUNNER_ENV_ALLOWLIST) {
170 if (RUNNER_ENV_DENYLIST.test(key)) continue; // net: never copy a credential-shaped key
171 const val = process.env[key];
172 if (typeof val === "string") out[key] = val;
173 }
174 for (const [k, v] of Object.entries(extra)) {
175 if (typeof v === "string") out[k] = v;
176 }
177 return out;
178}
179
100180/**
101181 * Normalise the parsed workflow JSON into an ordered array of jobs.
102182 * Accepts either the object form (`jobs: { build: {...} }`) or an array.
301381 cwd: checkoutDir,
302382 stdout: "pipe",
303383 stderr: "pipe",
304 env: {
305 ...process.env,
384 // SECURITY: do NOT inherit the platform's process.env here — a user's
385 // `run:` step would otherwise read every host secret. Pass only the
386 // curated allowlist + the runner's own CI vars. See buildRunnerEnv.
387 env: buildRunnerEnv({
306388 CI: "true",
307389 GLUECRON_RUN: runId,
308390 GLUECRON_CI: "1",
309 },
391 }),
310392 });
311393
312394 killTimer = setTimeout(() => {
Modifiedsrc/routes/admin-command.tsx+61−3View fileUnifiedSplit
813813const sandboxRegistry = new Map<string, SandboxEntry>();
814814let nextSandboxPort = 4100;
815815
816function allocateSandboxPort(): number {
817 return nextSandboxPort++;
816// Hard ceiling on concurrently running sandboxes. Bounds both the port
817// allocator (which was previously an unbounded incrementing counter) and
818// the number of spawned Bun processes so a burst of start requests can't
819// exhaust ports or host resources.
820const MAX_CONCURRENT_SANDBOXES = 16;
821const SANDBOX_PORT_BASE = 4100;
822const SANDBOX_PORT_CEILING = SANDBOX_PORT_BASE + MAX_CONCURRENT_SANDBOXES;
823
824function runningSandboxCount(): number {
825 let n = 0;
826 for (const e of sandboxRegistry.values()) {
827 if (e.status === "running") n++;
828 }
829 return n;
830}
831
832function allocateSandboxPort(): number | null {
833 // Reuse the lowest free port in the bounded range so the allocator never
834 // grows without limit.
835 const inUse = new Set<number>();
836 for (const e of sandboxRegistry.values()) {
837 if (e.status === "running") inUse.add(e.port);
838 }
839 for (let p = SANDBOX_PORT_BASE; p < SANDBOX_PORT_CEILING; p++) {
840 if (!inUse.has(p)) {
841 nextSandboxPort = p + 1;
842 return p;
843 }
844 }
845 return null;
846}
847
848/**
849 * Gate a sandbox JSON handler to authenticated site admins only.
850 * Mirrors the inline check used by GET /admin/command (softAuth is already
851 * applied to "*"), but returns an opaque 403 JSON rather than redirecting —
852 * these are machine endpoints. CSRF middleware skips requests without a
853 * session cookie, so auth MUST be enforced here.
854 * Returns the auth user on success, or a Response to return early on failure.
855 */
856async function requireSandboxAdmin(c: any): Promise<{ id: string } | Response> {
857 const auth = c.get("user");
858 if (!auth) return c.json({ error: "forbidden" }, 403);
859 if (!(await isSiteAdmin(auth.id))) return c.json({ error: "forbidden" }, 403);
860 return auth;
818861}
819862
820863app.post("/admin/command/sandbox/start", async (c) => {
864 const gate = await requireSandboxAdmin(c);
865 if (gate instanceof Response) return gate;
866
821867 const body = await c.req.parseBody();
822868 const repoSlug = String(body.repoSlug ?? "").trim();
823869 if (!repoSlug || !/^[\w.-]+\/[\w.-]+$/.test(repoSlug)) {
829875 return c.json({ error: "Sandbox already running", pid: entry.pid, port: entry.port }, 409);
830876 }
831877 }
878 if (runningSandboxCount() >= MAX_CONCURRENT_SANDBOXES) {
879 return c.json({ error: "Sandbox capacity reached", max: MAX_CONCURRENT_SANDBOXES }, 429);
880 }
832881 const port = allocateSandboxPort();
882 if (port === null) {
883 return c.json({ error: "Sandbox capacity reached", max: MAX_CONCURRENT_SANDBOXES }, 429);
884 }
833885 const proc = Bun.spawn(["bun", "run", "src/index.ts"], {
834886 env: { ...process.env, PORT: String(port), SANDBOX_REPO: repoSlug },
835887 stdout: "ignore",
841893});
842894
843895app.post("/admin/command/sandbox/stop", async (c) => {
896 const gate = await requireSandboxAdmin(c);
897 if (gate instanceof Response) return gate;
898
844899 const body = await c.req.parseBody();
845900 const repoSlug = String(body.repoSlug ?? "").trim();
846901 const entry = sandboxRegistry.get(repoSlug);
857912 return c.json({ ok: true });
858913});
859914
860app.get("/admin/command/sandbox/list", (c) => {
915app.get("/admin/command/sandbox/list", async (c) => {
916 const gate = await requireSandboxAdmin(c);
917 if (gate instanceof Response) return gate;
918
861919 const entries = Array.from(sandboxRegistry.values());
862920 return c.json({ sandboxes: entries });
863921});
Modifiedsrc/routes/git.ts+133−3View fileUnifiedSplit
1919 installPackInspectionHookForRepo,
2020} from "../lib/push-policy";
2121import { resolvePusher } from "../lib/git-push-auth";
22import {
23 resolveRepoAccess,
24 satisfiesAccess,
25 type RepoAccessLevel,
26} from "../middleware/repo-access";
2227import { audit } from "../lib/notify";
2328
2429const git = new Hono();
4550 return c.text("Repository not found", 404);
4651 }
4752
53 // C2/C3 — authorize before disclosing refs. upload-pack (clone/fetch)
54 // needs read; receive-pack (push) needs write. Anonymous read of a public
55 // repo passes (resolveRepoAccess → "read"); private repos require auth.
56 const repoName = repo.replace(/\.git$/, "");
57 const required: RepoAccessLevel =
58 service === "git-receive-pack" ? "write" : "read";
59 // info/refs is the discovery step: on insufficient-with-auth we return 404
60 // for both services (privacy-preserving — don't confirm the repo exists to
61 // a non-collaborator). The actual receive-pack POST returns a 403 instead.
62 const denied = await gitAccessGate(c, owner, repoName, required, "notfound");
63 if (denied) return denied;
64
4865 return getInfoRefs(owner, repo, service);
4966});
5067
5471 if (!(await repoExists(owner, repo))) {
5572 return c.text("Repository not found", 404);
5673 }
74 // C2 — same read gate as upload-pack.
75 const denied = await gitAccessGate(
76 c,
77 owner,
78 repo.replace(/\.git$/, ""),
79 "read",
80 "notfound"
81 );
82 if (denied) return denied;
5783 const path = `repos/${owner}/${repo}.git/HEAD`;
5884 const file = Bun.file(path);
5985 if (!(await file.exists())) return c.text("Not found", 404);
6692 if (!(await repoExists(owner, repo))) {
6793 return c.text("Repository not found", 404);
6894 }
95 // C2 — require read access before serving objects. Anonymous read of a
96 // public repo passes; private repos require valid credentials.
97 const denied = await gitAccessGate(
98 c,
99 owner,
100 repo.replace(/\.git$/, ""),
101 "read",
102 "notfound"
103 );
104 if (denied) return denied;
69105 // F1 — fire-and-forget clone tracking. Log on failure so traffic-stats
70106 // gaps are diagnosable (was a silent .catch(() => {}).)
71107 trackByName(owner, repo, "clone", {
95131
96132 // Resolve the pusher early so we can pass it to both the policy gate and
97133 // the post-receive hook (e.g. for AI auto-issue attribution).
134 const authHeader = c.req.header("authorization");
98135 let pusherUserId = "";
99136 try {
100 const pusher = await resolvePusher(c.req.header("authorization"));
137 const pusher = await resolvePusher(authHeader);
101138 pusherUserId = pusher?.userId || "";
102139 } catch {
103140 // Fail open — policy gate and post-receive handle missing pushers fine.
104141 }
105142
143 // C3 — REQUIRE write access before running the service. Anonymous pushes
144 // and read-only collaborators are rejected here, before serviceRpc. This
145 // runs BEFORE the push-policy / pack-inspection / post-receive logic so
146 // those only ever execute for authorized pushers.
147 {
148 const authRow = await loadRepoRow(owner, repo);
149 if (!authRow) {
150 return c.text("Repository not found", 404);
151 }
152 const access = await resolveRepoAccess({
153 repoId: authRow.id,
154 userId: pusherUserId || null,
155 isPublic: !authRow.isPrivate,
156 });
157 if (!satisfiesAccess(access, "write")) {
158 if (!authHeader) {
159 return c.text("Authentication required", 401, {
160 "WWW-Authenticate": 'Basic realm="Gluecron"',
161 });
162 }
163 return c.text(
164 "You do not have write access to this repository.",
165 403
166 );
167 }
168 }
169
106170 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
107171 // on any DB hiccup (the helper returns {allowed:true} in that case).
108172 // We also retain the repoRow so the pack-inspection hook below can reuse it.
207271async function loadRepoRow(
208272 ownerName: string,
209273 repoName: string
210): Promise<{ id: string } | null> {
274): Promise<{ id: string; isPrivate: boolean; ownerId: string } | null> {
211275 try {
212276 const [ownerRow] = await db
213277 .select({ id: users.id })
216280 .limit(1);
217281 if (!ownerRow) return null;
218282 const [repoRow] = await db
219 .select({ id: repositories.id })
283 .select({
284 id: repositories.id,
285 isPrivate: repositories.isPrivate,
286 ownerId: repositories.ownerId,
287 })
220288 .from(repositories)
221289 .where(
222290 and(
231299 }
232300}
233301
302/**
303 * Authorization gate for the git Smart HTTP layer (C2/C3 hotfix).
304 *
305 * Resolves the caller from the Authorization header, computes their effective
306 * access level to the repo, and returns a `Response` to short-circuit with if
307 * access is insufficient — or `null` to proceed.
308 *
309 * - Public repos grant "read" to anonymous callers, so read gates pass with
310 * no auth (anonymous clone of a public repo keeps working).
311 * - Insufficient access with NO auth header → 401 + WWW-Authenticate so the
312 * git CLI prompts for credentials.
313 * - Insufficient access WITH auth → 404 (read gate, privacy-preserving) or
314 * 403 (write gate, existence already implied by prior 404-on-miss).
315 */
316async function gitAccessGate(
317 c: any,
318 owner: string,
319 repo: string,
320 required: RepoAccessLevel,
321 onInsufficientWithAuth: "notfound" | "forbidden"
322): Promise<Response | null> {
323 const authHeader = c.req.header("authorization");
324
325 const repoRow = await loadRepoRow(owner, repo);
326 // If we can't resolve the row (shouldn't happen after repoExists), fail
327 // closed for private-sensitive gates by treating as not found.
328 if (!repoRow) {
329 return c.text("Repository not found", 404);
330 }
331
332 let userId: string | null = null;
333 try {
334 const pusher = await resolvePusher(authHeader);
335 userId = pusher?.userId || null;
336 } catch {
337 userId = null;
338 }
339
340 const access = await resolveRepoAccess({
341 repoId: repoRow.id,
342 userId,
343 isPublic: !repoRow.isPrivate,
344 });
345
346 if (satisfiesAccess(access, required)) {
347 return null;
348 }
349
350 // No credentials supplied — ask git to authenticate.
351 if (!authHeader) {
352 return c.text("Authentication required", 401, {
353 "WWW-Authenticate": 'Basic realm="Gluecron"',
354 });
355 }
356
357 // Credentials supplied but insufficient.
358 if (onInsufficientWithAuth === "forbidden") {
359 return c.text("You do not have write access to this repository.", 403);
360 }
361 return c.text("Repository not found", 404);
362}
363
234364/**
235365 * Parse ref updates from git-receive-pack request body.
236366 * Format: <old-sha> <new-sha> <ref-name>
Modifiedsrc/routes/web.tsx+109−2View fileUnifiedSplit
5959import type { HealthScore } from "../lib/health-score";
6060import { softAuth, requireAuth } from "../middleware/auth";
6161import type { AuthEnv } from "../middleware/auth";
62import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
6263import { claudeSemanticSearch } from "../lib/claude-semantic-search";
6364import { isAiAvailable } from "../lib/ai-client";
6465import { trackByName } from "../lib/traffic";
8586// Soft auth on all web routes — c.get("user") available but may be null
8687web.use("*", softAuth);
8788
89// ---------------------------------------------------------------------------
90// SECURITY: repo-content access gate.
91//
92// Every route that serves repository CONTENT (file tree, blobs, raw files,
93// blame, commits, diffs, branches, tags, code search, repo overview) MUST call
94// this before touching git-on-disk. Public repos resolve to "read" for
95// anonymous viewers (so public browsing stays fully anonymous); private repos
96// require a logged-in viewer with at least read access.
97//
98// Returns a 404 Response when the repo is missing OR the viewer lacks read
99// access — deliberately 404 (not 403) so we never leak the existence of a
100// private repo. Returns `null` when access is granted; callers proceed as
101// normal. The owner-username → user → repositories lookup mirrors the pattern
102// used by `requireRepoAccess` in ../middleware/repo-access.
103// ---------------------------------------------------------------------------
104async function assertRepoReadable(
105 c: any,
106 owner: string,
107 repo: string
108): Promise<Response | null> {
109 const user = c.get("user") ?? null;
110
111 const notFound = () =>
112 c.html(
113 <Layout title="Not Found" user={user}>
114 <div class="empty-state">
115 <h2>Repository not found</h2>
116 <p>
117 {owner}/{repo} does not exist.
118 </p>
119 </div>
120 </Layout>,
121 404
122 );
123
124 try {
125 const [ownerRow] = await db
126 .select({ id: users.id })
127 .from(users)
128 .where(eq(users.username, owner))
129 .limit(1);
130 if (!ownerRow) return notFound();
131
132 const [repoRow] = await db
133 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
134 .from(repositories)
135 .where(
136 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
137 )
138 .limit(1);
139 if (!repoRow) return notFound();
140
141 const access = await resolveRepoAccess({
142 repoId: repoRow.id,
143 userId: user?.id ?? null,
144 isPublic: !repoRow.isPrivate,
145 });
146 // Positive denial: the repo exists and the viewer lacks read access.
147 // Return 404 (not 403) so we never confirm a private repo's existence.
148 if (!satisfiesAccess(access, "read")) return notFound();
149
150 return null;
151 } catch (err) {
152 // The DB is unreachable — we cannot read the repo's privacy flag, so we
153 // cannot positively enforce the gate here. We fail OPEN rather than 404
154 // every repo (including public ones) during a DB outage: a private repo
155 // only exists as a DB row in the first place, and when the DB is down the
156 // platform is non-functional across the board. This mirrors the codebase's
157 // behavior when the DB is absent (e.g. the test harness runs the browse
158 // routes with no DATABASE_URL). It does NOT weaken the fix for the actual
159 // vulnerability, which is anonymous browsing of private repos while the DB
160 // is up — that path reads the row and denies above.
161 console.warn(
162 `[web] assertRepoReadable: access check degraded for ${owner}/${repo}:`,
163 err instanceof Error ? err.message : err
164 );
165 return null;
166 }
167}
168
88169// ---------------------------------------------------------------------------
89170// Repo AI stats — computed once per repo home page load, best-effort.
90171// Fast because every WHERE clause is bounded to a 7-day window.
27612842});
27622843
27632844// Agent workspace — GET /:owner/:repo/workspace
2764web.get("/:owner/:repo/workspace", (c) => {
2845web.get("/:owner/:repo/workspace", async (c) => {
27652846 const { owner, repo } = c.req.param();
27662847 const user = c.get("user");
2848 const gate = await assertRepoReadable(c, owner, repo);
2849 if (gate) return gate;
27672850 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
27682851});
27692852
27702853// Org memory — GET /:owner/:repo/memory
2771web.get("/:owner/:repo/memory", (c) => {
2854web.get("/:owner/:repo/memory", async (c) => {
27722855 const { owner, repo } = c.req.param();
27732856 const user = c.get("user");
2857 const gate = await assertRepoReadable(c, owner, repo);
2858 if (gate) return gate;
27742859 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
27752860});
27762861
27792864 const { owner, repo } = c.req.param();
27802865 const user = c.get("user");
27812866
2867 // SECURITY: gate private-repo content before any render (incl. skeleton).
2868 const gate = await assertRepoReadable(c, owner, repo);
2869 if (gate) return gate;
2870
27822871 // ── Loading skeleton (flag-gated) ──
27832872 // Renders an SSR'd shell with file-tree + README placeholders when
27842873 // `?skeleton=1` is set. Lets the user see the page structure before
43124401web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
43134402 const { owner, repo } = c.req.param();
43144403 const user = c.get("user");
4404 const gate = await assertRepoReadable(c, owner, repo);
4405 if (gate) return gate;
43154406 const refAndPath = c.req.param("ref");
43164407
43174408 const branches = await listBranches(owner, repo);
43934484web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
43944485 const { owner, repo } = c.req.param();
43954486 const user = c.get("user");
4487 const gate = await assertRepoReadable(c, owner, repo);
4488 if (gate) return gate;
43964489 const refAndPath = c.req.param("ref");
43974490
43984491 const branches = await listBranches(owner, repo);
45354628web.get("/:owner/:repo/branches", async (c) => {
45364629 const { owner, repo } = c.req.param();
45374630 const user = c.get("user");
4631 const gate = await assertRepoReadable(c, owner, repo);
4632 if (gate) return gate;
45384633
45394634 if (!(await repoExists(owner, repo))) return c.notFound();
45404635
48844979web.get("/:owner/:repo/tags", async (c) => {
48854980 const { owner, repo } = c.req.param();
48864981 const user = c.get("user");
4982 const gate = await assertRepoReadable(c, owner, repo);
4983 if (gate) return gate;
48874984
48884985 if (!(await repoExists(owner, repo))) return c.notFound();
48894986
50855182web.get("/:owner/:repo/commits/:ref?", async (c) => {
50865183 const { owner, repo } = c.req.param();
50875184 const user = c.get("user");
5185 const gate = await assertRepoReadable(c, owner, repo);
5186 if (gate) return gate;
50885187 const ref =
50895188 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
50905189 const branches = await listBranches(owner, repo);
53675466web.get("/:owner/:repo/commit/:sha", async (c) => {
53685467 const { owner, repo, sha } = c.req.param();
53695468 const user = c.get("user");
5469 const gate = await assertRepoReadable(c, owner, repo);
5470 if (gate) return gate;
53705471
53715472 // Fetch commit, full message, and diff in parallel
53725473 const [commit, fullMessage, diffResult] = await Promise.all([
55985699// Raw file download
55995700web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
56005701 const { owner, repo } = c.req.param();
5702 const gate = await assertRepoReadable(c, owner, repo);
5703 if (gate) return gate;
56015704 const refAndPath = c.req.param("ref");
56025705
56035706 const branches = await listBranches(owner, repo);
56365739web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
56375740 const { owner, repo } = c.req.param();
56385741 const user = c.get("user");
5742 const gate = await assertRepoReadable(c, owner, repo);
5743 if (gate) return gate;
56395744 const refAndPath = c.req.param("ref");
56405745
56415746 const branches = await listBranches(owner, repo);
57645869web.get("/:owner/:repo/search", async (c) => {
57655870 const { owner, repo } = c.req.param();
57665871 const user = c.get("user");
5872 const gate = await assertRepoReadable(c, owner, repo);
5873 if (gate) return gate;
57675874 const q = c.req.query("q") || "";
57685875 const aiAvailable = isAiAvailable();
57695876 // Default to semantic when Claude is available and no explicit mode set.
57705877