Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions agents/analyzer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ export interface AnalysisResult {

export class CodeAnalyzerAgent {
private projectRoot: string;
private allowBuildScripts: boolean;

constructor(projectRoot: string) {
constructor(projectRoot: string, allowBuildScripts: boolean = true) {
this.projectRoot = projectRoot;
this.allowBuildScripts = allowBuildScripts;
}

/**
Expand All @@ -63,8 +65,12 @@ export class CodeAnalyzerAgent {
issues.push(...eslintIssues);

// 3. Build check
const buildIssues = await this.runBuildCheck();
issues.push(...buildIssues);
if (this.allowBuildScripts) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Disabling only the build step still executes untrusted repository code via ESLint config/plugins.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agents/analyzer/index.ts, line 68:

<comment>Disabling only the build step still executes untrusted repository code via ESLint config/plugins.</comment>

<file context>
@@ -63,8 +65,12 @@ export class CodeAnalyzerAgent {
     // 3. Build check
-    const buildIssues = await this.runBuildCheck();
-    issues.push(...buildIssues);
+    if (this.allowBuildScripts) {
+      const buildIssues = await this.runBuildCheck();
+      issues.push(...buildIssues);
</file context>
Fix with Cubic

const buildIssues = await this.runBuildCheck();
issues.push(...buildIssues);
} else {
console.log('[CodeAnalyzer] Build check skipped for untrusted repository');
}

const errors = issues.filter(i => i.severity === 'error').length;
const warnings = issues.filter(i => i.severity === 'warning').length;
Expand Down
62 changes: 52 additions & 10 deletions agents/verifier/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Instrumented with W&B Weave for observability.
*/

import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import type {
Expand Down Expand Up @@ -38,6 +38,8 @@ export class VerifierAgent implements IVerifierAgent {
private autoCommit: boolean;
private currentFailureReport?: FailureReport;

private static readonly COMMIT_MESSAGE_MAX_LENGTH = 200;

constructor(projectRoot: string = process.cwd(), options: VerifierOptions = {}) {
this.projectRoot = projectRoot;
this.useRedis = options.useRedis ?? true;
Expand Down Expand Up @@ -179,9 +181,14 @@ export class VerifierAgent implements IVerifierAgent {
*/
private async commitFix(patch: Patch): Promise<void> {
try {
const commitMessage = `fix: ${patch.description}\n\nApplied by PatchPilot`;
execSync(`git add ${patch.file}`, { cwd: this.projectRoot, stdio: 'pipe' });
execSync(`git commit -m "${commitMessage}"`, {
const safeFilePath = this.getValidatedFilePath(patch.file);
const commitMessage = `fix: ${this.sanitizeCommitDescription(patch.description)}\n\nApplied by QAgent`;

execFileSync('git', ['add', '--', safeFilePath], {
cwd: this.projectRoot,
stdio: 'pipe',
});
execFileSync('git', ['commit', '-m', commitMessage], {
Comment on lines +184 to +191
cwd: this.projectRoot,
stdio: 'pipe',
});
Expand Down Expand Up @@ -210,7 +217,7 @@ export class VerifierAgent implements IVerifierAgent {
}

private async deploy(): Promise<string> {
execSync('git push', {
execFileSync('git', ['push'], {
cwd: this.projectRoot,
stdio: 'pipe',
});
Expand Down Expand Up @@ -266,7 +273,7 @@ export class VerifierAgent implements IVerifierAgent {
*/
private async applyPatch(patch: Patch): Promise<boolean> {
try {
const fullPath = path.join(this.projectRoot, patch.file);
const fullPath = this.getValidatedFilePath(patch.file);
const sourceCode = fs.readFileSync(fullPath, 'utf-8');

// Parse the diff to get the change details
Expand Down Expand Up @@ -313,7 +320,7 @@ export class VerifierAgent implements IVerifierAgent {
* Create a backup of a file
*/
private async backupFile(filePath: string): Promise<string> {
const fullPath = path.join(this.projectRoot, filePath);
const fullPath = this.getValidatedFilePath(filePath);
const backupPath = `${fullPath}.backup.${Date.now()}`;
fs.copyFileSync(fullPath, backupPath);
return backupPath;
Expand All @@ -323,7 +330,7 @@ export class VerifierAgent implements IVerifierAgent {
* Restore a file from backup
*/
private async restoreFile(filePath: string, backupPath: string): Promise<void> {
const fullPath = path.join(this.projectRoot, filePath);
const fullPath = this.getValidatedFilePath(filePath);
fs.copyFileSync(backupPath, fullPath);
this.cleanupBackup(backupPath);
}
Expand All @@ -344,7 +351,7 @@ export class VerifierAgent implements IVerifierAgent {
*/
private async validateSyntax(filePath: string): Promise<boolean> {
try {
const fullPath = path.join(this.projectRoot, filePath);
const fullPath = this.getValidatedFilePath(filePath);
const content = fs.readFileSync(fullPath, 'utf-8');

// Basic bracket balance check
Expand All @@ -371,7 +378,7 @@ export class VerifierAgent implements IVerifierAgent {
// Try to run TypeScript check using project config
try {
// Use project's tsconfig to ensure JSX and other settings are applied
execSync(`npx tsc --noEmit --project tsconfig.json 2>&1`, {
execFileSync('npx', ['tsc', '--noEmit', '--project', 'tsconfig.json'], {
cwd: this.projectRoot,
stdio: 'pipe',
});
Expand Down Expand Up @@ -441,6 +448,41 @@ export class VerifierAgent implements IVerifierAgent {
console.error('Error recording fix in knowledge base:', error);
}
}

/**
* Resolve and validate that patch file paths stay within the repository.
*/
private getValidatedFilePath(filePath: string): string {
if (!filePath || typeof filePath !== 'string') {
throw new Error('Invalid patch file path');
}

const normalizedPath = path.normalize(filePath);
if (path.isAbsolute(normalizedPath)) {
throw new Error('Absolute file paths are not allowed in patches');
}

const resolvedPath = path.resolve(this.projectRoot, normalizedPath);
const rootPath = path.resolve(this.projectRoot);

if (resolvedPath !== rootPath && !resolvedPath.startsWith(`${rootPath}${path.sep}`)) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Path validation allows the project root directory itself (".") to pass, which in commitFix would cause git add to stage the entire repository. Since this is a path traversal guard, it should reject paths that don't resolve to a file strictly inside the root.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agents/verifier/index.ts, line 468:

<comment>Path validation allows the project root directory itself (`"."`) to pass, which in `commitFix` would cause `git add` to stage the entire repository. Since this is a path traversal guard, it should reject paths that don't resolve to a file strictly inside the root.</comment>

<file context>
@@ -441,6 +448,41 @@ export class VerifierAgent implements IVerifierAgent {
+    const resolvedPath = path.resolve(this.projectRoot, normalizedPath);
+    const rootPath = path.resolve(this.projectRoot);
+
+    if (resolvedPath !== rootPath && !resolvedPath.startsWith(`${rootPath}${path.sep}`)) {
+      throw new Error('Patch file path resolves outside project root');
+    }
</file context>
Suggested change
if (resolvedPath !== rootPath && !resolvedPath.startsWith(`${rootPath}${path.sep}`)) {
if (!resolvedPath.startsWith(`${rootPath}${path.sep}`)) {
Fix with Cubic

throw new Error('Patch file path resolves outside project root');
}

return resolvedPath;
}

/**
* Keep commit subjects safe, readable, and bounded.
*/
private sanitizeCommitDescription(description: string): string {
const fallback = 'Bug fix';
const normalized = description.trim().replace(/[\r\n]+/g, ' ');
if (!normalized) {
return fallback;
}
return normalized.slice(0, VerifierAgent.COMMIT_MESSAGE_MAX_LENGTH);
}
Comment on lines +452 to +485
}

export default VerifierAgent;
7 changes: 7 additions & 0 deletions app/api/runs/[runId]/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getRunAsync } from '@/lib/dashboard/run-store';
import { getSession } from '@/lib/auth/session';
import Browserbase from '@browserbasehq/sdk';

// GET /api/runs/[runId]/session - Get Browserbase session debug URLs
Expand All @@ -8,12 +9,18 @@ export async function GET(
{ params }: { params: Promise<{ runId: string }> }
) {
const { runId } = await params;
const session = await getSession();
const run = await getRunAsync(runId);

if (!run) {
return NextResponse.json({ error: 'Run not found' }, { status: 404 });
}

const requesterId = session?.user?.id;
if (requesterId === undefined || run.ownerId !== requesterId) {
return NextResponse.json({ error: 'Run not found' }, { status: 404 });
}

if (!run.sessionId) {
return NextResponse.json({
hasSession: false,
Expand Down
1 change: 1 addition & 0 deletions app/api/runs/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function POST(request: NextRequest) {
}

const run = createRun({
ownerId: session?.user?.id,
repoId: repoId || repoName,
repoName,
testSpecs: [],
Expand Down
16 changes: 14 additions & 2 deletions app/api/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { executeAdHocRun } from '@/lib/queue/ad-hoc-runner';
export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
const session = await getSession();
const userId = session?.user?.id;

const { searchParams } = request.nextUrl;
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20', 10)));
Expand All @@ -22,6 +25,10 @@ export async function GET(request: NextRequest) {

let runs = await getAllRunsAsync();

if (userId !== undefined) {
runs = runs.filter((r) => r.ownerId === userId);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Scope stats to the same owner filter as runs; this response still leaks cross-user aggregate counts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/runs/route.ts, line 29:

<comment>Scope `stats` to the same owner filter as `runs`; this response still leaks cross-user aggregate counts.</comment>

<file context>
@@ -22,6 +25,10 @@ export async function GET(request: NextRequest) {
   let runs = await getAllRunsAsync();
 
+  if (userId !== undefined) {
+    runs = runs.filter((r) => r.ownerId === userId);
+  }
+
</file context>
Fix with Cubic

}
Comment on lines +28 to +30
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Reject unauthenticated GET requests here; when userId is undefined this branch is skipped and the endpoint still returns every run.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/runs/route.ts, line 28:

<comment>Reject unauthenticated GET requests here; when `userId` is undefined this branch is skipped and the endpoint still returns every run.</comment>

<file context>
@@ -22,6 +25,10 @@ export async function GET(request: NextRequest) {
 
   let runs = await getAllRunsAsync();
 
+  if (userId !== undefined) {
+    runs = runs.filter((r) => r.ownerId === userId);
+  }
</file context>
Suggested change
if (userId !== undefined) {
runs = runs.filter((r) => r.ownerId === userId);
}
if (userId === undefined) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
runs = runs.filter((r) => r.ownerId === userId);
Fix with Cubic


if (statusFilter) {
runs = runs.filter((run) => run.status === statusFilter);
}
Expand Down Expand Up @@ -86,12 +93,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Repository is required' }, { status: 400 });
}

const session = cloudMode ? await getSession() : null;
const githubToken = session?.accessToken || undefined;
const session = await getSession();
const githubToken = cloudMode ? session?.accessToken || undefined : undefined;

if (cloudMode && !githubToken) {
return NextResponse.json({ error: 'GitHub authentication required' }, { status: 401 });
}

const resolvedRepoId = repoId || (cloudMode ? repoName : 'local');
const resolvedRepoName = repoName || 'Demo App';
const run = createRun({
ownerId: session?.user?.id,
repoId: resolvedRepoId,
repoName: resolvedRepoName,
testSpecs,
Expand Down
14 changes: 12 additions & 2 deletions lib/auth/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export function getGitHubAuthUrl(state: string): string {
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}

export async function exchangeCodeForToken(code: string): Promise<string> {
export async function exchangeCodeForToken(
params: string | { code: string; redirectUri?: string; codeVerifier?: string; state?: string }
): Promise<string> {
const requestParams =
typeof params === 'string'
? { code: params }
: params;

const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Expand All @@ -25,7 +32,10 @@ export async function exchangeCodeForToken(code: string): Promise<string> {
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
code: requestParams.code,
...(requestParams.redirectUri ? { redirect_uri: requestParams.redirectUri } : {}),
...(requestParams.codeVerifier ? { code_verifier: requestParams.codeVerifier } : {}),
...(requestParams.state ? { state: requestParams.state } : {}),
}),
});

Expand Down
32 changes: 32 additions & 0 deletions lib/auth/session-secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const DEV_SESSION_SECRET_GLOBAL_KEY = '__PATCHPILOT_DEV_SESSION_SECRET__';
const TEST_FALLBACK_SECRET = 'test-session-secret';

type GlobalWithSecret = typeof globalThis & {
[DEV_SESSION_SECRET_GLOBAL_KEY]?: string;
};

function getDevSessionSecret(): string {
const globalWithSecret = globalThis as GlobalWithSecret;
if (!globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY]) {
globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY] = crypto.randomUUID();
}

return globalWithSecret[DEV_SESSION_SECRET_GLOBAL_KEY];
}

export function getSessionSecret(): string {
const envSecret = process.env.SESSION_SECRET;
if (envSecret) {
return envSecret;
}

if (process.env.NODE_ENV === 'test') {
return TEST_FALLBACK_SECRET;
}

if (process.env.NODE_ENV === 'production') {
throw new Error('SESSION_SECRET environment variable is required in production');
}

return getDevSessionSecret();
}
15 changes: 1 addition & 14 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
import type { Session, GitHubUser, GitHubRepo, SessionPayload } from '@/lib/types';
import { getSessionSecret } from '@/lib/auth/session-secret';
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This import changes session signing to use a runtime-local dev fallback secret, which can make middleware reject valid local sessions when SESSION_SECRET is unset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/auth/session.ts, line 4:

<comment>This import changes session signing to use a runtime-local dev fallback secret, which can make middleware reject valid local sessions when `SESSION_SECRET` is unset.</comment>

<file context>
@@ -1,22 +1,9 @@
 import { cookies } from 'next/headers';
 import { SignJWT, jwtVerify } from 'jose';
 import type { Session, GitHubUser, GitHubRepo, SessionPayload } from '@/lib/types';
+import { getSessionSecret } from '@/lib/auth/session-secret';
 
 const SESSION_COOKIE = 'qagent_session';
</file context>
Fix with Cubic


const SESSION_COOKIE = 'qagent_session';
const TEST_FALLBACK_SECRET = 'test-session-secret';

function getSessionSecret(): string {
const secret = process.env.SESSION_SECRET;
if (secret) {
return secret;
}

if (process.env.NODE_ENV === 'test') {
return TEST_FALLBACK_SECRET;
}

throw new Error('SESSION_SECRET environment variable is required');
}

export function getSessionKey(): Uint8Array {
return new TextEncoder().encode(getSessionSecret());
Expand Down
2 changes: 2 additions & 0 deletions lib/dashboard/run-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ function persistRun(run: Run): void {
}

export function createRun(data: {
ownerId?: number;
repoId: string;
repoName: string;
testSpecs: TestSpec[];
maxIterations: number;
}): Run {
const run: Run = {
id: crypto.randomUUID(),
ownerId: data.ownerId,
repoId: data.repoId,
repoName: data.repoName,
status: 'pending',
Expand Down
5 changes: 4 additions & 1 deletion lib/git/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ export async function installDependencies(
const pm = detectPackageManager(repoPath);
console.log(`[GitClone] Installing dependencies with ${pm}...`);

const installCommand = pm === 'npm' ? 'npm install' : `${pm} install`;
const installCommand =
pm === 'npm'
? 'npm install --ignore-scripts'
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Skipping install scripts here can leave cloned repos unusable for setupLocalRepo. Some projects need prepare/postinstall or native build steps to generate artifacts before startDevServer() runs, so this shared helper should not disable scripts unconditionally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/git/clone.ts, line 118:

<comment>Skipping install scripts here can leave cloned repos unusable for `setupLocalRepo`. Some projects need `prepare`/`postinstall` or native build steps to generate artifacts before `startDevServer()` runs, so this shared helper should not disable scripts unconditionally.</comment>

<file context>
@@ -113,7 +113,10 @@ export async function installDependencies(
-  const installCommand = pm === 'npm' ? 'npm install' : `${pm} install`;
+  const installCommand =
+    pm === 'npm'
+      ? 'npm install --ignore-scripts'
+      : `${pm} install --ignore-scripts`;
 
</file context>
Fix with Cubic

: `${pm} install --ignore-scripts`;

try {
execSync(installCommand, {
Expand Down
2 changes: 1 addition & 1 deletion lib/queue/ad-hoc-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function runCodeFirst(
emitAgentStarted(runId, 'tester');
updateRunAgent(runId, 'tester');

const analyzer = new CodeAnalyzerAgent(clonedRepo.repoPath);
const analyzer = new CodeAnalyzerAgent(clonedRepo.repoPath, false);
const analysisResult = await analyzer.analyze();

emitAgentCompleted(runId, 'tester');
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export type AgentType = 'tester' | 'triage' | 'fixer' | 'verifier';

export interface Run {
id: string;
ownerId?: number;
repoId: string;
repoName: string;
status: RunStatus;
Expand Down
Loading
Loading