diff --git a/src/agents/challenger.ts b/src/agents/challenger.ts index 78fe057..6249b40 100644 --- a/src/agents/challenger.ts +++ b/src/agents/challenger.ts @@ -1,5 +1,13 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; +interface PlanIssue { + type: "circular_dep" | "invalid_wave" | "missing_must_haves" | "uncovered_requirement"; + description: string; + taskId?: string; +} + export class ChallengerAgent extends BaseAgent { readonly name = "challenger"; readonly description = "Stress-tests plans with binding verdicts. Only escalates when confidence < 0.60."; @@ -8,6 +16,7 @@ export class ChallengerAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Challenging plan..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +24,150 @@ export class ChallengerAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const planPath = path.join(context.project_path, ".opencode", "plans", `v0.${context.phase}-plan.md`); + const issues = this.mechanicalChallenge(context.project_path, planPath); + const output = this.formatIssues(issues); + return { - success: false, - output: "Plan challenge requires an intelligence backend. Configure one with: ci init --backend", + success: issues.length === 0, + output, artifacts_created: [], decisions: 0, - escalations: 0, + escalations: issues.filter((i) => i.type === "circular_dep" || i.type === "uncovered_requirement").length, duration_ms: Date.now() - start, - error: "No intelligence backend available", + error: issues.length > 0 ? `${issues.length} plan issue(s) found` : undefined, }; } + + mechanicalChallenge(projectPath: string, planPath: string): PlanIssue[] { + const issues: PlanIssue[] = []; + + if (!fs.existsSync(planPath)) { + const altPaths = [ + path.join(projectPath, "PLAN.md"), + path.join(projectPath, ".opencode", "plans", "plan.md"), + ]; + const found = altPaths.find((p) => fs.existsSync(p)); + if (!found) return issues; + return this.validatePlan(found); + } + + return this.validatePlan(planPath); + } + + private validatePlan(planPath: string): PlanIssue[] { + const issues: PlanIssue[] = []; + const content = fs.readFileSync(planPath, "utf-8"); + + const taskRegex = /\|\s*(\S+[-\d\w]*)\s*\|.*?\|\s*(\d+)\s*\|/g; + const tasks: Array<{ id: string; wave: number; deps: string[]; hasMustHaves: boolean; reqIds: string[] }> = []; + + let match; + while ((match = taskRegex.exec(content)) !== null) { + const id = match[1]; + const wave = parseInt(match[2]); + const depMatch = content.match(new RegExp(`${id}[^|]*\\|[^|]*\\|[^|]*\\|[^|]*\\|([^|]*)\\|`, "i")); + const deps = depMatch ? depMatch[1].split(/[,\s]+/).filter(Boolean) : []; + const mustHaveMatch = content.match(new RegExp(`${id}[^|]*\\|[^|]*\\|[^|]*\\|([^|]*)\\|`, "i")); + const hasMustHaves = mustHaveMatch ? mustHaveMatch[1].trim().length > 0 : false; + const reqMatch = content.match(new RegExp(`${id}[\\s\\S]*?REQ-ID[^|]*\\|([^|]*)\\|`, "i")); + const reqIds = reqMatch ? reqMatch[1].split(/[,\s]+/).filter((s) => s.match(/^[A-Z]+-\d+$/)) : []; + + tasks.push({ id, wave, deps, hasMustHaves, reqIds }); + } + + for (const task of tasks) { + if (!task.hasMustHaves) { + issues.push({ + type: "missing_must_haves", + description: `Task ${task.id} has no must-haves defined`, + taskId: task.id, + }); + } + } + + for (const task of tasks) { + for (const dep of task.deps) { + const depTask = tasks.find((t) => t.id === dep); + if (depTask && depTask.wave > task.wave) { + issues.push({ + type: "invalid_wave", + description: `Task ${task.id} (wave ${task.wave}) depends on ${dep} (wave ${depTask.wave}) — later wave`, + taskId: task.id, + }); + } + } + } + + const visited = new Set(); + const recursionStack = new Set(); + + for (const task of tasks) { + if (this.hasCycle(tasks, task.id, visited, recursionStack)) { + issues.push({ + type: "circular_dep", + description: `Circular dependency detected involving task ${task.id}`, + taskId: task.id, + }); + break; + } + } + + const allReqIds = new Set(); + for (const task of tasks) { + for (const reqId of task.reqIds) { + allReqIds.add(reqId); + } + } + + const reqSection = content.match(/REQ-ID.*?\n([\s\S]*?)(?=\n##|\n$)/); + if (reqSection) { + const definedReqs = [...reqSection[1].matchAll(/([A-Z]+-\d+)/g)].map((m) => m[1]); + for (const req of definedReqs) { + if (!allReqIds.has(req)) { + issues.push({ + type: "uncovered_requirement", + description: `Requirement ${req} is not covered by any task`, + }); + } + } + } + + return issues; + } + + private hasCycle( + tasks: Array<{ id: string; deps: string[] }>, + taskId: string, + visited: Set, + recursionStack: Set + ): boolean { + if (recursionStack.has(taskId)) return true; + if (visited.has(taskId)) return false; + + visited.add(taskId); + recursionStack.add(taskId); + + const task = tasks.find((t) => t.id === taskId); + if (task) { + for (const dep of task.deps) { + if (this.hasCycle(tasks, dep, visited, recursionStack)) { + return true; + } + } + } + + recursionStack.delete(taskId); + return false; + } + + private formatIssues(issues: PlanIssue[]): string { + if (issues.length === 0) return "Plan validation passed — no issues found."; + const lines: string[] = ["Plan Issues Found:", ""]; + for (const issue of issues) { + lines.push(`[${issue.type}]${issue.taskId ? ` Task ${issue.taskId}:` : ""} ${issue.description}`); + } + return lines.join("\n"); + } } \ No newline at end of file diff --git a/src/agents/debugger.ts b/src/agents/debugger.ts index 08e25c8..1de18ae 100644 --- a/src/agents/debugger.ts +++ b/src/agents/debugger.ts @@ -1,5 +1,21 @@ +import { execSync } from "node:child_process"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; +interface StackFrame { + file: string; + line: number; + column?: number; + function?: string; +} + +interface DebugResult { + rootFile: string; + rootLine: number; + rootFunction?: string; + introducingCommit?: string; + suggestion?: string; +} + export class DebuggerAgent extends BaseAgent { readonly name = "debugger"; readonly description = "Autonomous debugging. Auto-fixes when root cause confidence > 0.60, escalates otherwise."; @@ -8,6 +24,7 @@ export class DebuggerAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Running autonomous debug..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +32,130 @@ export class DebuggerAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const debugResult = this.mechanicalDebug(context.project_path, context.specification); + const output = this.formatDebugResult(debugResult); + return { - success: false, - output: "Debugging requires an intelligence backend. Configure one with: ci init --backend", + success: !!debugResult.introducingCommit, + output, artifacts_created: [], decisions: 0, - escalations: 0, + escalations: debugResult.introducingCommit ? 0 : 1, duration_ms: Date.now() - start, - error: "No intelligence backend available", + error: debugResult.introducingCommit ? undefined : "Could not identify introducing commit via git bisect", }; } + + mechanicalDebug(projectPath: string, stackTrace: string): DebugResult { + const frames = this.parseStackTrace(stackTrace); + + if (frames.length === 0) { + return { rootFile: "", rootLine: 0, suggestion: "No parseable stack frames found in input" }; + } + + const topFrame = frames[0]; + const result: DebugResult = { + rootFile: topFrame.file, + rootLine: topFrame.line, + rootFunction: topFrame.function, + }; + + try { + const bisectResult = this.gitBisect(projectPath, topFrame.file, topFrame.line); + if (bisectResult) { + result.introducingCommit = bisectResult; + result.suggestion = `git revert ${bisectResult}`; + } + } catch {} + + return result; + } + + parseStackTrace(trace: string): StackFrame[] { + const frames: StackFrame[] = []; + const patterns = [ + /at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/g, + /at\s+(.+?)\s+\((.+?):(\d+)\)/g, + /at\s+(.+?):(\d+):(\d+)/g, + /(.+?):(\d+):(\d+)/g, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(trace)) !== null) { + if (pattern === patterns[0] || pattern === patterns[1]) { + frames.push({ + function: match[1], + file: match[2], + line: parseInt(match[3]), + column: match[4] ? parseInt(match[4]) : undefined, + }); + } else { + frames.push({ + file: match[1], + line: parseInt(match[2]), + column: match[3] ? parseInt(match[3]) : undefined, + }); + } + } + if (frames.length > 0) break; + } + + return frames; + } + + private gitBisect(projectPath: string, file: string, line: number): string | null { + try { + execSync("git bisect start", { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + execSync("git bisect bad HEAD", { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + + try { + const firstCommit = execSync("git rev-list --max-parents=0 HEAD", { + cwd: projectPath, encoding: "utf-8", stdio: "pipe", timeout: 5000, + }).trim(); + execSync(`git bisect good ${firstCommit}`, { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + } catch { + execSync("git bisect good HEAD~20", { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + } + + let result: string | null = null; + for (let i = 0; i < 50; i++) { + const output = execSync("git bisect run true", { + cwd: projectPath, encoding: "utf-8", stdio: "pipe", timeout: 30000, + }); + if (output.includes("is the first bad commit")) { + const hashMatch = output.match(/^([a-f0-9]+)/m); + result = hashMatch ? hashMatch[1] : null; + break; + } + } + + try { + execSync("git bisect reset", { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + } catch {} + + return result; + } catch { + try { + execSync("git bisect reset", { cwd: projectPath, stdio: "pipe", timeout: 5000 }); + } catch {} + return null; + } + } + + private formatDebugResult(result: DebugResult): string { + const lines: string[] = ["Debug Analysis:", ""]; + if (result.rootFile) { + lines.push(`Root location: ${result.rootFile}:${result.rootLine}`); + if (result.rootFunction) lines.push(`Function: ${result.rootFunction}`); + } + if (result.introducingCommit) { + lines.push(`Introduced by: ${result.introducingCommit}`); + } + if (result.suggestion) { + lines.push(`Suggestion: ${result.suggestion}`); + } + return lines.join("\n"); + } } \ No newline at end of file diff --git a/src/agents/doc-writer.ts b/src/agents/doc-writer.ts index 150d463..4bd332d 100644 --- a/src/agents/doc-writer.ts +++ b/src/agents/doc-writer.ts @@ -1,5 +1,13 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; +interface DocUpdate { + file: string; + updates: string[]; +} + export class DocWriterAgent extends BaseAgent { readonly name = "doc-writer"; readonly description = "Autonomous documentation writer."; @@ -8,6 +16,7 @@ export class DocWriterAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Writing documentation..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +24,162 @@ export class DocWriterAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const updates = this.mechanicalDocUpdate(context.project_path, context.phase); + const output = this.formatUpdates(updates); + return { - success: false, - output: "Documentation writing requires an intelligence backend.", - artifacts_created: [], + success: true, + output, + artifacts_created: updates.map((u) => u.file), decisions: 0, escalations: 0, duration_ms: Date.now() - start, - error: "No intelligence backend available", }; } + + mechanicalDocUpdate(projectPath: string, phase: number): DocUpdate[] { + const updates: DocUpdate[] = []; + const ciDir = path.join(projectPath, ".ciagent"); + + if (!fs.existsSync(ciDir)) return updates; + + const roadmapUpdates = this.updateRoadmapPhaseStatus(ciDir, phase); + if (roadmapUpdates.length > 0) { + updates.push({ file: ".ciagent/ROADMAP.md", updates: roadmapUpdates }); + } + + const reqUpdates = this.updateRequirementsStatus(projectPath, phase); + if (reqUpdates.length > 0) { + updates.push({ file: ".ciagent/REQUIREMENTS.md", updates: reqUpdates }); + } + + const decisionUpdates = this.updateProjectDecisions(ciDir, phase); + if (decisionUpdates.length > 0) { + updates.push({ file: ".ciagent/PROJECT.md", updates: decisionUpdates }); + } + + if (updates.length > 0) { + try { + execSync("git add -A", { cwd: projectPath, stdio: "pipe" }); + } catch {} + } + + return updates; + } + + private updateRoadmapPhaseStatus(ciDir: string, phase: number): string[] { + const roadmapPath = path.join(ciDir, "ROADMAP.md"); + if (!fs.existsSync(roadmapPath)) return []; + + const content = fs.readFileSync(roadmapPath, "utf-8"); + const phasePattern = new RegExp( + `\\|\\s*${phase}\\s*\\|([^|]+)\\|([^|]+)\\|`, + "g" + ); + + let updated = content; + let match; + const updates: string[] = []; + + while ((match = phasePattern.exec(content)) !== null) { + const currentStatus = match[2].trim().toLowerCase(); + if (currentStatus !== "complete") { + updated = updated.replace( + match[0], + match[0].replace(/in.progress|pending|not.started/i, "complete") + ); + updates.push(`Phase ${phase}: status → complete`); + } + } + + if (updated !== content) { + fs.writeFileSync(roadmapPath, updated, "utf-8"); + } + + return updates; + } + + private updateRequirementsStatus(projectPath: string, phase: number): string[] { + const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md"); + if (!fs.existsSync(reqPath)) return []; + + const content = fs.readFileSync(reqPath, "utf-8"); + let updated = content; + const updates: string[] = []; + + const pendingForPhase = content.match( + new RegExp(`\\|[^|]*\\|[^|]*\\|[^|]*\\|\\s*${phase}\\s*\\|\\s*pending\\s*\\|`, "g") + ); + if (pendingForPhase) { + for (const line of pendingForPhase) { + updated = updated.replace(line, line.replace(/pending/, "covered")); + updates.push(`Requirement updated to covered (phase ${phase})`); + } + } + + if (updated !== content) { + fs.writeFileSync(reqPath, updated, "utf-8"); + } + + return updates; + } + + private updateProjectDecisions(ciDir: string, phase: number): string[] { + const projectPath = path.join(ciDir, "PROJECT.md"); + if (!fs.existsSync(projectPath)) return []; + + const content = fs.readFileSync(projectPath, "utf-8"); + const gitLogDecisions = this.getRecentDecisions(phase); + + if (gitLogDecisions.length === 0) return []; + + const updates: string[] = []; + for (const d of gitLogDecisions) { + if (!content.includes(d.id)) { + updates.push(`Added decision ${d.id}: ${d.decision}`); + } + } + + return updates; + } + + private getRecentDecisions(phase: number): Array<{ id: string; decision: string }> { + try { + const raw = execSync( + `git log --all --max-count=20 --format="%B%x01"`, + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 } + ); + const decisions: Array<{ id: string; decision: string }> = []; + const entries = raw.split("\x01").filter(Boolean); + + for (const entry of entries) { + const ciMatch = entry.match(/---ci---[\s\S]*?---\/ci---/); + if (!ciMatch) continue; + const phaseMatch = ciMatch[0].match(/phase:\s*(\d+)/); + if (!phaseMatch || parseInt(phaseMatch[1]) !== phase) continue; + + const decMatches = [...ciMatch[0].matchAll(/id:\s*(D-\d+)[\s\S]*?decision:\s*(.+)/g)]; + for (const m of decMatches) { + decisions.push({ id: m[1], decision: m[2].trim() }); + } + } + + return decisions; + } catch { + return []; + } + } + + private formatUpdates(updates: DocUpdate[]): string { + if (updates.length === 0) return "No documentation updates needed."; + const lines: string[] = ["Documentation Updates:", ""]; + for (const u of updates) { + lines.push(`${u.file}:`); + for (const update of u.updates) { + lines.push(` - ${update}`); + } + } + return lines.join("\n"); + } } \ No newline at end of file diff --git a/src/agents/security-auditor.ts b/src/agents/security-auditor.ts index f5addd0..1e73cba 100644 --- a/src/agents/security-auditor.ts +++ b/src/agents/security-auditor.ts @@ -1,13 +1,52 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; +interface SecurityFinding { + stride_category: string; + cwe: string; + severity: "low" | "medium" | "high"; + disposition: "accept" | "mitigate" | "flag"; + file: string; + description: string; +} + +const SECURITY_PATTERNS: Array<{ + pattern: RegExp; + category: string; + cwe: string; + description: string; + severity: "low" | "medium" | "high"; + confidence: number; +}> = [ + { pattern: /password\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-259", description: "Hardcoded password", severity: "high", confidence: 0.95 }, + { pattern: /api[_-]?key\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded API key", severity: "high", confidence: 0.95 }, + { pattern: /secret\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded secret", severity: "high", confidence: 0.95 }, + { pattern: /token\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded token", severity: "medium", confidence: 0.80 }, + { pattern: /eval\s*\(\s*[^'"]*\$\{/g, category: "tampering", cwe: "CWE-94", description: "eval() with dynamic content", severity: "high", confidence: 0.90 }, + { pattern: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*[^'"]*[\$`]/g, category: "elevation_of_privilege", cwe: "CWE-78", description: "Command execution with interpolation", severity: "high", confidence: 0.85 }, + { pattern: /catch\s*\(\w*\)\s*\{\s*\}/g, category: "repudiation", cwe: "CWE-778", description: "Empty catch block", severity: "medium", confidence: 0.85 }, + { pattern: /jwt\.decode\s*\(/g, category: "spoofing", cwe: "CWE-287", description: "JWT decode without verify", severity: "high", confidence: 0.85 }, + { pattern: /(?:__proto__|constructor\s*\[|prototype\s*\[)/g, category: "elevation_of_privilege", cwe: "CWE-1321", description: "Prototype pollution", severity: "high", confidence: 0.90 }, + { pattern: /(?:md5|sha1|des|rc4)\s*\(/gi, category: "information_disclosure", cwe: "CWE-328", description: "Weak crypto", severity: "medium", confidence: 0.90 }, + { pattern: /express\.json\s*\(\s*\)/g, category: "denial_of_service", cwe: "CWE-400", description: "JSON parser without size limit", severity: "medium", confidence: 0.80 }, +]; + export class SecurityAuditorAgent extends BaseAgent { readonly name = "security-auditor"; readonly description = "Auto-dispositions threats: low=accept, medium=mitigate, high=escalate."; readonly workflow = "verify"; + private confidenceThreshold: number; + + constructor(confidenceThreshold: number = 0.6) { + super(); + this.confidenceThreshold = confidenceThreshold; + } async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Running security audit..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +54,74 @@ export class SecurityAuditorAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const findings = this.mechanicalAudit(context.project_path); + const highCount = findings.filter((f) => f.severity === "high").length; + const output = this.formatFindings(findings); + return { - success: false, - output: "Security auditing requires an intelligence backend. Configure one with: ci init --backend", + success: highCount === 0, + output, artifacts_created: [], decisions: 0, - escalations: 0, + escalations: highCount, duration_ms: Date.now() - start, - error: "No intelligence backend available", + error: highCount > 0 ? `${highCount} high-severity finding(s) require escalation` : undefined, }; } + + mechanicalAudit(projectPath: string): SecurityFinding[] { + const findings: SecurityFinding[] = []; + const srcDir = path.join(projectPath, "src"); + + if (!fs.existsSync(srcDir)) return findings; + + this.scanDirectory(srcDir, projectPath, findings); + return findings; + } + + private getDisposition(severity: SecurityFinding["severity"], confidence: number): SecurityFinding["disposition"] { + if (severity === "low") return "accept"; + if (confidence >= this.confidenceThreshold) return "flag"; + return "mitigate"; + } + + private scanDirectory(dir: string, projectPath: string, findings: SecurityFinding[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") { + this.scanDirectory(fullPath, projectPath, findings); + } else if ( + entry.isFile() && + (entry.name.endsWith(".ts") || entry.name.endsWith(".js")) && + !entry.name.endsWith(".test.ts") && + !entry.name.endsWith(".d.ts") + ) { + const content = fs.readFileSync(fullPath, "utf-8"); + for (const { pattern, category, cwe, description, severity, confidence } of SECURITY_PATTERNS) { + pattern.lastIndex = 0; + if (pattern.test(content)) { + findings.push({ + stride_category: category, + cwe, + severity, + disposition: this.getDisposition(severity, confidence), + file: path.relative(projectPath, fullPath), + description, + }); + } + } + } + } + } + + private formatFindings(findings: SecurityFinding[]): string { + if (findings.length === 0) return "No security findings — audit passed."; + const lines: string[] = ["Security Audit Findings:", ""]; + for (const f of findings) { + lines.push(`[${f.stride_category}|${f.cwe}|${f.disposition}] ${f.severity.toUpperCase()}: ${f.description} (${f.file})`); + } + return lines.join("\n"); + } } \ No newline at end of file