From d86acb906bf4ca267d47acf45e157446776d750a Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 2 Mar 2026 20:14:23 +0000 Subject: [PATCH] feat: harden codebase container access limits and metrics --- opencto/opencto-api-worker/README.md | 3 + .../src/__tests__/codebaseRuns.test.ts | 192 +++++++++++++++++- .../opencto-api-worker/src/codebaseRuns.ts | 109 +++++++++- opencto/opencto-api-worker/src/errors.ts | 4 +- opencto/opencto-api-worker/src/index.ts | 4 + 5 files changed, 300 insertions(+), 12 deletions(-) diff --git a/opencto/opencto-api-worker/README.md b/opencto/opencto-api-worker/README.md index b8d8576..8ff632c 100644 --- a/opencto/opencto-api-worker/README.md +++ b/opencto/opencto-api-worker/README.md @@ -126,8 +126,11 @@ This deploys the worker to Cloudflare Workers. - `GET /api/v1/codebase/runs/:id` - Get run status and metrics - `GET /api/v1/codebase/runs/:id/events` - Poll run events/log lines - `POST /api/v1/codebase/runs/:id/cancel` - Cancel queued/running run +- `GET /api/v1/codebase/metrics` - Per-user run metrics for the last 24 hours Runtime controls: +- Create/cancel access restricted to `owner` and `cto` roles +- Repository URL validation restricted to `https://github.com//[.git]` - Command normalization + allowlist template enforcement - Shell chaining guard (`&&`, `;`, `|`, backticks, `$(`) - Per-user concurrent and daily run quotas diff --git a/opencto/opencto-api-worker/src/__tests__/codebaseRuns.test.ts b/opencto/opencto-api-worker/src/__tests__/codebaseRuns.test.ts index 5eeb46d..5ccd6cd 100644 --- a/opencto/opencto-api-worker/src/__tests__/codebaseRuns.test.ts +++ b/opencto/opencto-api-worker/src/__tests__/codebaseRuns.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import worker from '../index' import { __setContainerDispatcherForTests } from '../codebaseRuns' -import type { Env } from '../types' +import type { Env, UserRole } from '../types' type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out' @@ -48,6 +48,13 @@ class MockD1Database { run.status = status } + setRunTiming(runId: string, startedAt: string | null, completedAt: string | null): void { + const run = this.runs.get(runId) + if (!run) return + run.started_at = startedAt + run.completed_at = completedAt + } + countEvents(runId: string): number { return this.events.filter((event) => event.run_id === runId).length } @@ -184,6 +191,18 @@ class MockD1Database { return { results: filtered.map((event) => structuredClone(event) as T) } } + if (normalized.startsWith('select status, started_at, completed_at from codebase_runs where user_id = ? and created_at >= ?')) { + const [userId, since] = args + const filtered = Array.from(this.runs.values()) + .filter((run) => run.user_id === String(userId) && run.created_at >= String(since)) + .map((run) => ({ + status: run.status, + started_at: run.started_at, + completed_at: run.completed_at, + }) as T) + return { results: filtered } + } + throw new Error(`Unhandled all SQL: ${sql}`) } } @@ -264,6 +283,53 @@ async function createRun(env: Env, body?: Record): Promise { + const payload = { + sub, + email: 'test@example.com', + name: 'Test User', + role, + provider: 'github', + exp: Math.floor(Date.now() / 1000) + 3600, + } + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(env.JWT_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payloadB64)) + const sigB64 = base64UrlEncode(new Uint8Array(sig)) + return `${payloadB64}.${sigB64}` +} + +async function createRunWithAuth(env: Env, token: string, body?: Record): Promise { + return await worker.fetch( + new Request('https://api.opencto.works/api/v1/codebase/runs', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(body ?? { + repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', + commands: ['git clone https://github.com/Hey-Salad/CTO-AI.git', 'npm run build'], + }), + }), + env, + ) +} + describe('Codebase run endpoints', () => { it('POST /api/v1/codebase/runs succeeds and persists normalized commands', async () => { const db = new MockD1Database() @@ -284,6 +350,21 @@ describe('Codebase run endpoints', () => { expect(body.run.timeoutSeconds).toBe(1800) }) + it('POST /api/v1/codebase/runs returns 403 for non-owner/cto roles', async () => { + const env = createMockEnv() + const developerToken = await createSessionToken(env, 'developer') + const res = await createRunWithAuth(env, developerToken, { + repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', + commands: ['npm run build'], + }) + const body = await res.json() as { code?: string; status?: number; details?: { code?: string } } + + expect(res.status).toBe(403) + expect(body.code).toBe('FORBIDDEN') + expect(body.status).toBe(403) + expect(body.details?.code).toBe('CODEBASE_ACCESS_DENIED') + }) + it('POST /api/v1/codebase/runs rejects disallowed or chained commands', async () => { const env = createMockEnv() @@ -299,6 +380,20 @@ describe('Codebase run endpoints', () => { expect(body.error).toContain('Shell chaining') }) + it('POST /api/v1/codebase/runs rejects invalid repo URL', async () => { + const env = createMockEnv() + const res = await createRun(env, { + repoUrl: 'https://gitlab.com/Hey-Salad/CTO-AI.git', + commands: ['npm run build'], + }) + const body = await res.json() as { code?: string; status?: number; error?: string } + + expect(res.status).toBe(400) + expect(body.code).toBe('BAD_REQUEST') + expect(body.status).toBe(400) + expect(body.error).toContain('github.com') + }) + it('POST /api/v1/codebase/runs rejects unauthorized requests', async () => { const env = createMockEnv({ ENVIRONMENT: 'production' }) @@ -326,11 +421,34 @@ describe('Codebase run endpoints', () => { const body = await res.json() as { code?: string; status?: number; error?: string } expect(res.status).toBe(429) - expect(body.code).toBe('QUOTA_EXCEEDED') + expect(body.code).toBe('CODEBASE_CONCURRENCY_LIMIT') expect(body.status).toBe(429) expect(body.error).toContain('Concurrent run quota') }) + it('POST /api/v1/codebase/runs returns quota error when daily cap is exceeded', async () => { + const db = new MockD1Database() + const env = createMockEnv({ + CODEBASE_MAX_CONCURRENT_RUNS: '5', + CODEBASE_DAILY_RUN_LIMIT: '1', + }, db) + await createRun(env, { + repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', + commands: ['npm run build'], + }) + + const res = await createRun(env, { + repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', + commands: ['npm run build'], + }) + const body = await res.json() as { code?: string; status?: number; error?: string } + + expect(res.status).toBe(429) + expect(body.code).toBe('CODEBASE_DAILY_LIMIT') + expect(body.status).toBe(429) + expect(body.error).toContain('Daily run quota') + }) + it('POST /api/v1/codebase/runs rejects invalid timeout payloads', async () => { const env = createMockEnv() @@ -375,7 +493,7 @@ describe('Codebase run endpoints', () => { expect(res.status).toBe(201) expect(body.run.status).toBe('succeeded') - expect(db.countEvents(runId)).toBe(6) + expect(db.countEvents(runId)).toBe(7) }) it('GET /api/v1/codebase/runs/:id returns run when found', async () => { @@ -432,7 +550,7 @@ describe('Codebase run endpoints', () => { const body = await res.json() as { events: Array<{ seq: number }> } expect(res.status).toBe(200) - expect(body.events.map((event) => event.seq)).toEqual([1, 2, 3]) + expect(body.events.map((event) => event.seq)).toEqual([1, 2, 3, 4]) }) it('POST /api/v1/codebase/runs/:id/cancel transitions queued/running to canceled', async () => { @@ -456,6 +574,33 @@ describe('Codebase run endpoints', () => { expect(body.run.status).toBe('canceled') }) + it('POST /api/v1/codebase/runs/:id/cancel returns 403 for non-owner/cto roles', async () => { + const db = new MockD1Database() + const env = createMockEnv({}, db) + const ownerToken = await createSessionToken(env, 'owner', 'github-shared-user') + const developerToken = await createSessionToken(env, 'developer', 'github-shared-user') + + const created = await createRunWithAuth(env, ownerToken, { + repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', + commands: ['npm run build'], + }) + const createdBody = await created.json() as { run: { id: string } } + + const res = await worker.fetch( + new Request(`https://api.opencto.works/api/v1/codebase/runs/${createdBody.run.id}/cancel`, { + method: 'POST', + headers: { Authorization: `Bearer ${developerToken}` }, + }), + env, + ) + const body = await res.json() as { code?: string; status?: number; details?: { code?: string } } + + expect(res.status).toBe(403) + expect(body.code).toBe('FORBIDDEN') + expect(body.status).toBe(403) + expect(body.details?.code).toBe('CODEBASE_ACCESS_DENIED') + }) + it('POST /api/v1/codebase/runs/:id/cancel is no-op for terminal status', async () => { const db = new MockD1Database() const env = createMockEnv({}, db) @@ -478,4 +623,43 @@ describe('Codebase run endpoints', () => { expect(body.run.status).toBe('succeeded') expect(db.countEvents(createdBody.run.id)).toBe(beforeEventCount) }) + + it('GET /api/v1/codebase/metrics returns basic aggregation for last 24h', async () => { + const db = new MockD1Database() + const env = createMockEnv({}, db) + + const createdA = await createRun(env, { repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', commands: ['npm run build'] }) + const runA = (await createdA.json() as { run: { id: string } }).run.id + db.setRunStatus(runA, 'succeeded') + db.setRunTiming(runA, '2026-03-02T10:00:00.000Z', '2026-03-02T10:00:10.000Z') + + const createdB = await createRun(env, { repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', commands: ['npm run build'] }) + const runB = (await createdB.json() as { run: { id: string } }).run.id + db.setRunStatus(runB, 'failed') + db.setRunTiming(runB, '2026-03-02T11:00:00.000Z', '2026-03-02T11:00:30.000Z') + + const createdC = await createRun(env, { repoUrl: 'https://github.com/Hey-Salad/CTO-AI.git', commands: ['npm run build'] }) + const runC = (await createdC.json() as { run: { id: string } }).run.id + db.setRunStatus(runC, 'canceled') + db.setRunTiming(runC, null, null) + + const res = await worker.fetch( + new Request('https://api.opencto.works/api/v1/codebase/metrics', { + headers: { Authorization: 'Bearer demo-token' }, + }), + env, + ) + const body = await res.json() as { + totals: { created: number; succeeded: number; failed: number; canceled: number; avgDurationMs: number } + windowHours: number + } + + expect(res.status).toBe(200) + expect(body.windowHours).toBe(24) + expect(body.totals.created).toBe(3) + expect(body.totals.succeeded).toBe(1) + expect(body.totals.failed).toBe(1) + expect(body.totals.canceled).toBe(1) + expect(body.totals.avgDurationMs).toBe(20000) + }) }) diff --git a/opencto/opencto-api-worker/src/codebaseRuns.ts b/opencto/opencto-api-worker/src/codebaseRuns.ts index 0160552..b9f7b4f 100644 --- a/opencto/opencto-api-worker/src/codebaseRuns.ts +++ b/opencto/opencto-api-worker/src/codebaseRuns.ts @@ -2,6 +2,7 @@ import { getContainer } from '@cloudflare/containers' import type { RequestContext } from './types' import { BadRequestException, + ForbiddenException, InternalServerException, NotFoundException, NotImplementedException, @@ -51,6 +52,7 @@ const MIN_TIMEOUT_SECONDS = 60 const MAX_TIMEOUT_SECONDS = 1800 const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled', 'timed_out']) const BLOCKED_CHAINING_PATTERN = /(?:&&|;|\|\||\||`|\$\()/ +const CODEBASE_ALLOWED_ROLES = new Set(['owner', 'cto']) interface CodebaseRunRow { id: string @@ -164,6 +166,39 @@ function normalizeAndValidateCommands(raw: unknown): string[] { return commands } +function normalizeAndValidateRepoUrl(raw: unknown): string { + if (typeof raw !== 'string' || !raw.trim()) { + throw new BadRequestException('repoUrl is required') + } + + const repoUrl = raw.trim() + let parsed: URL + try { + parsed = new URL(repoUrl) + } catch { + throw new BadRequestException('repoUrl must be a valid URL') + } + + if (parsed.protocol !== 'https:') { + throw new BadRequestException('repoUrl must use https') + } + + if (parsed.hostname !== 'github.com') { + throw new BadRequestException('repoUrl must target github.com') + } + + if (parsed.search || parsed.hash) { + throw new BadRequestException('repoUrl must not include query parameters or fragments') + } + + const path = parsed.pathname.replace(/^\/+|\/+$/g, '') + if (!/^[^/]+\/[^/]+(?:\.git)?$/.test(path)) { + throw new BadRequestException('repoUrl must match https://github.com//[.git]') + } + + return `https://github.com/${path}` +} + function normalizeTimeoutSeconds(input: unknown, bounds: { defaultTimeout: number; minTimeout: number; maxTimeout: number }): number { if (input === null || typeof input === 'undefined') return bounds.defaultTimeout const numeric = typeof input === 'number' ? input : Number(input) @@ -347,7 +382,7 @@ async function enforceRunLimits(ctx: RequestContext): Promise { ).bind(ctx.userId).first<{ count: number }>() if ((concurrent?.count ?? 0) >= concurrentCap) { - throw new TooManyRequestsException('Concurrent run quota exceeded', { + throw new TooManyRequestsException('Concurrent run quota exceeded', 'CODEBASE_CONCURRENCY_LIMIT', { concurrentCap, }) } @@ -359,13 +394,22 @@ async function enforceRunLimits(ctx: RequestContext): Promise { ).bind(ctx.userId, dayStart).first<{ count: number }>() if ((daily?.count ?? 0) >= dailyCap) { - throw new TooManyRequestsException('Daily run quota exceeded', { + throw new TooManyRequestsException('Daily run quota exceeded', 'CODEBASE_DAILY_LIMIT', { dailyCap, dayStart, }) } } +function assertCodebaseRunWriteAccess(ctx: RequestContext): void { + if (CODEBASE_ALLOWED_ROLES.has(ctx.user.role)) return + throw new ForbiddenException('Only owner or cto can create/cancel codebase runs', { + code: 'CODEBASE_ACCESS_DENIED', + allowedRoles: [...CODEBASE_ALLOWED_ROLES], + userRole: ctx.user.role, + }) +} + function normalizeContainerResponse(payload: unknown): ContainerExecutionResult { const body = (payload && typeof payload === 'object') ? payload as Record : {} const rawStatus = typeof body.status === 'string' ? body.status : 'failed' @@ -445,11 +489,9 @@ export async function createCodebaseRun( ctx: RequestContext, ): Promise { await ensureSchema(ctx) + assertCodebaseRunWriteAccess(ctx) - const repoUrl = (payload.repoUrl ?? '').trim() - if (!repoUrl) { - throw new BadRequestException('repoUrl is required') - } + const repoUrl = normalizeAndValidateRepoUrl(payload.repoUrl) const commands = normalizeAndValidateCommands(payload.commands) const timeoutBounds = getTimeoutBounds(ctx) @@ -502,6 +544,16 @@ export async function createCodebaseRun( message: `Requested commands: ${commands.join(' | ')}`, }, ctx) + await appendEvent(runId, { + level: 'system', + eventType: 'run.audit', + message: 'Run command audit recorded.', + payload: { + repoUrl, + normalizedCommands: commands, + }, + }, ctx) + if (executionMode === 'container') { await setRunStatus(runId, ctx, { status: 'running' }) await appendEvent(runId, { @@ -601,6 +653,7 @@ export async function getCodebaseRunEvents(runId: string, request: Request, ctx: // POST /api/v1/codebase/runs/:id/cancel export async function cancelCodebaseRun(runId: string, ctx: RequestContext): Promise { + assertCodebaseRunWriteAccess(ctx) const row = await getRunRow(runId, ctx) if (TERMINAL_RUN_STATUSES.has(row.status)) { @@ -621,3 +674,47 @@ export async function cancelCodebaseRun(runId: string, ctx: RequestContext): Pro const updated = await getRunRow(runId, ctx) return jsonResponse({ run: mapRun(updated) }) } + +// GET /api/v1/codebase/metrics +export async function getCodebaseRunMetrics(ctx: RequestContext): Promise { + await ensureSchema(ctx) + const since = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString() + const rows = await ctx.env.DB.prepare( + `SELECT status, started_at, completed_at + FROM codebase_runs + WHERE user_id = ? AND created_at >= ?`, + ).bind(ctx.userId, since).all<{ status: RunStatus; started_at: string | null; completed_at: string | null }>() + + const result = { + created: 0, + succeeded: 0, + failed: 0, + canceled: 0, + avgDurationMs: 0, + } + + let durationTotalMs = 0 + let durationCount = 0 + for (const row of rows.results ?? []) { + result.created += 1 + if (row.status === 'succeeded') result.succeeded += 1 + if (row.status === 'failed' || row.status === 'timed_out') result.failed += 1 + if (row.status === 'canceled') result.canceled += 1 + + if (row.started_at && row.completed_at) { + const startedMs = Date.parse(row.started_at) + const completedMs = Date.parse(row.completed_at) + if (Number.isFinite(startedMs) && Number.isFinite(completedMs) && completedMs >= startedMs) { + durationTotalMs += (completedMs - startedMs) + durationCount += 1 + } + } + } + + result.avgDurationMs = durationCount > 0 ? Math.round(durationTotalMs / durationCount) : 0 + return jsonResponse({ + windowHours: 24, + since, + totals: result, + }) +} diff --git a/opencto/opencto-api-worker/src/errors.ts b/opencto/opencto-api-worker/src/errors.ts index 1adb86d..1ad6142 100644 --- a/opencto/opencto-api-worker/src/errors.ts +++ b/opencto/opencto-api-worker/src/errors.ts @@ -51,8 +51,8 @@ export class ConflictException extends ApiException { } export class TooManyRequestsException extends ApiException { - constructor(message = 'Too many requests', details?: Record) { - super(429, 'QUOTA_EXCEEDED', message, details) + constructor(message = 'Too many requests', code = 'QUOTA_EXCEEDED', details?: Record) { + super(429, code, message, details) this.name = 'TooManyRequestsException' } } diff --git a/opencto/opencto-api-worker/src/index.ts b/opencto/opencto-api-worker/src/index.ts index 2b6b6b4..ac37c56 100644 --- a/opencto/opencto-api-worker/src/index.ts +++ b/opencto/opencto-api-worker/src/index.ts @@ -212,6 +212,10 @@ async function route(path: string, request: Request, ctx: RequestContext): Promi return await codebaseRuns.cancelCodebaseRun(runId, ctx) } + if (path === '/api/v1/codebase/metrics' && method === 'GET') { + return await codebaseRuns.getCodebaseRunMetrics(ctx) + } + // Onboarding endpoints if (path === '/api/v1/onboarding' && method === 'GET') { return await onboarding.getOnboarding(ctx)