praxis-sec 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/assets/praxis-architecture.svg +304 -0
  2. package/assets/praxis-logo.svg +38 -0
  3. package/cli/agents/abom-generator.js +1 -1
  4. package/cli/agents/agent-attestation-agent.js +10 -1
  5. package/cli/agents/agent-config-scanner.js +1 -1
  6. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  7. package/cli/agents/base-agent.js +1 -1
  8. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  9. package/cli/agents/html-reporter.js +10 -10
  10. package/cli/agents/index.js +2 -2
  11. package/cli/agents/injection-tester.js +8 -1
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -1
  14. package/cli/agents/model-file-scanner.js +1 -1
  15. package/cli/agents/orchestrator.js +375 -355
  16. package/cli/agents/prompt-injection-prober.js +228 -228
  17. package/cli/bin/praxis.js +7 -3
  18. package/cli/commands/agent-fix.js +1091 -1245
  19. package/cli/commands/audit.js +1232 -1216
  20. package/cli/commands/baseline.js +3 -2
  21. package/cli/commands/benchmark.js +1 -1
  22. package/cli/commands/ci.js +50 -25
  23. package/cli/commands/deps.js +11 -5
  24. package/cli/commands/diff.js +2 -1
  25. package/cli/commands/env-audit.js +1 -1
  26. package/cli/commands/fix.js +1 -1
  27. package/cli/commands/legal.js +2 -1
  28. package/cli/commands/mcp.js +3 -2
  29. package/cli/commands/openclaw.js +3 -6
  30. package/cli/commands/red-team.js +351 -350
  31. package/cli/commands/remediate.js +1 -1
  32. package/cli/commands/rotate.js +1 -1
  33. package/cli/commands/rules.js +1 -1
  34. package/cli/commands/scan-standard.js +3 -6
  35. package/cli/commands/scan.js +554 -554
  36. package/cli/commands/score.js +1 -1
  37. package/cli/commands/undo.js +22 -77
  38. package/cli/commands/vibe-check.js +3 -2
  39. package/cli/commands/watch.js +6 -5
  40. package/cli/core/fix-plan.js +274 -0
  41. package/cli/core/fs.js +27 -0
  42. package/cli/core/git-clone.js +8 -6
  43. package/cli/core/glob.js +56 -0
  44. package/cli/core/output/html-theme.js +158 -158
  45. package/cli/core/output/json.js +56 -48
  46. package/cli/core/output/sarif.js +2 -2
  47. package/cli/core/paths.js +91 -0
  48. package/cli/core/web/jobs.js +2 -2
  49. package/cli/core/web/server.js +19 -8
  50. package/cli/data/threatpacks/latest.json +41 -41
  51. package/cli/integrations/github-action.js +136 -0
  52. package/cli/utils/cache-manager.js +2 -1
  53. package/cli/utils/plugin-loader.js +15 -95
  54. package/cli/utils/rule-import.js +227 -227
  55. package/cli/utils/rule-registry.js +425 -425
  56. package/cli/utils/scan-fingerprint.js +1 -1
  57. package/cli/utils/score-history.js +118 -118
  58. package/docs/USAGE.md +16 -9
  59. package/docs/design/WEB-UI.md +4 -5
  60. package/package.json +81 -71
@@ -1,1245 +1,1091 @@
1
- /**
2
- * Praxis Security Agent — Interactive Fix Loop
3
- * ================================================
4
- *
5
- * Scans your codebase, then for each affected file:
6
- * 1. Generates a precise multi-edit fix plan via LLM (one plan per file,
7
- * addressing every finding in that file at once)
8
- * 2. Shows you exactly what it will change (unified diff with line numbers)
9
- * 3. Asks you to accept, skip, or quit
10
- * 4. Applies the changes atomically
11
- * 5. Re-scans to verify the findings are resolved
12
- * 6. Logs every change to .praxis/fixes.jsonl
13
- *
14
- * USAGE:
15
- * praxis agent [path] Interactive fix loop
16
- * praxis agent . --plan-only Generate plans, never write
17
- * praxis agent . --severity high Only fix high+ severity
18
- * praxis agent . --branch fixes Create a branch, commit per file
19
- * praxis agent . --pr After fixing, push and open a PR
20
- * praxis agent . --provider deepseek-flash
21
- *
22
- * SAFETY:
23
- * - Refuses to operate on a dirty git tree (use --allow-dirty to override)
24
- * - Always shows a diff before any write
25
- * - Re-scans after each batch to verify the fix
26
- * - Plans may create new files (e.g., .env.example) but cannot edit
27
- * .env, secrets, lockfiles, or build artifacts
28
- * - Every applied change is logged for audit & undo (`praxis undo`)
29
- */
30
-
31
- import fs from 'fs';
32
- import { fileURLToPath } from 'url';
33
- import path, { dirname } from 'path';
34
- import { createInterface } from 'readline';
35
-
36
- const __filename = fileURLToPath(import.meta.url); // praxis-ignore — module's own path via import.meta.url, not user input
37
- const __dirname = dirname(__filename);
38
- const praxisDir = path.resolve(__dirname, '../..');
39
- import { execFileSync } from 'child_process';
40
- import chalk from 'chalk';
41
- import ora from 'ora';
42
- import { autoDetectProvider } from '../providers/llm-provider.js';
43
- import { auditCommand } from './audit.js';
44
- import { ASTParser } from '../core/ast/index.js';
45
- import * as output from '../utils/output.js';
46
-
47
- const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
48
- const NEVER_EDIT = [
49
- /(^|\/)\.env(\.|$)/i,
50
- /\.pem$|\.key$|\.p12$|\.pfx$/i,
51
- /package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
52
- /(^|\/)node_modules\//,
53
- /(^|\/)dist\//,
54
- /(^|\/)build\//,
55
- /\.min\.(js|css)$/,
56
- ];
57
- // Files the agent IS allowed to create or update freely (companions to fixes)
58
- const SAFE_NEW_FILES = [
59
- /(^|\/)\.env\.example$/i,
60
- /(^|\/)\.env\.sample$/i,
61
- /(^|\/)\.gitignore$/i,
62
- ];
63
-
64
- const FIX_LOG_DIR = '.praxis';
65
- const FIX_LOG_FILE = 'fixes.jsonl';
66
-
67
- // =============================================================================
68
- // MAIN
69
- // =============================================================================
70
-
71
- export async function agentFixCommand(targetPath = '.', options = {}) {
72
- const root = path.resolve(targetPath);
73
-
74
- if (!fs.existsSync(root)) {
75
- output.error(`Path does not exist: ${root}`);
76
- process.exit(1);
77
- }
78
-
79
- console.log();
80
- output.header('Praxis — Security Agent');
81
- console.log(chalk.gray(' I will scan, plan each fix, ask before changing anything,'));
82
- console.log(chalk.gray(' and verify the fix worked. You stay in control.'));
83
- console.log();
84
-
85
- // ── Git safety check ─────────────────────────────────────────────────────
86
- const initialBranch = getCurrentBranch(root);
87
- if (!options.allowDirty) {
88
- const state = checkGitState(root);
89
- if (state === 'not-a-repo') {
90
- console.log(chalk.yellow(' Note: this is not a git repository.'));
91
- console.log(chalk.gray(' Changes cannot be reverted automatically.'));
92
- if (options.ci) {
93
- console.log(chalk.gray(' CI mode active: automatically continuing.'));
94
- } else {
95
- const ok = await confirm(' Continue anyway?');
96
- if (!ok) { console.log(chalk.gray(' Aborted.\n')); return; }
97
- }
98
- } else if (state === 'dirty') {
99
- output.error('Working tree has uncommitted changes.');
100
- console.log(chalk.gray(' Commit or stash first, or pass --allow-dirty.'));
101
- process.exit(1);
102
- }
103
- }
104
-
105
- // ── Optional branch isolation ────────────────────────────────────────────
106
- let branchCreated = null;
107
- if (options.branch) {
108
- if (!initialBranch) {
109
- console.log(chalk.yellow(' --branch requires a git repository. Skipping branch creation.'));
110
- } else {
111
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
112
- const branchName = options.branch === true
113
- ? `praxis/fixes-${stamp}`
114
- : String(options.branch);
115
- try {
116
- execFileSync('git', ['checkout', '-b', branchName], { cwd: root, stdio: 'pipe' });
117
- branchCreated = branchName;
118
- console.log(chalk.gray(` Branch: ${chalk.cyan(branchName)}`));
119
- } catch (err) {
120
- output.error(`Could not create branch ${branchName}: ${err.message}`);
121
- process.exit(1);
122
- }
123
- }
124
- }
125
-
126
- // ── Load LLM provider ────────────────────────────────────────────────────
127
- const provider = autoDetectProvider(root, {
128
- provider: options.provider,
129
- model: options.model,
130
- think: options.think || false,
131
- });
132
- if (!provider) {
133
- output.error('No LLM provider available.');
134
- console.log(chalk.gray(' Set one of: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MOONSHOT_API_KEY, XAI_API_KEY'));
135
- process.exit(1);
136
- }
137
- console.log(chalk.gray(` Provider: ${chalk.cyan(provider.name)}`));
138
-
139
- if (options.sandbox) {
140
- console.log(chalk.gray(' Sandbox: ' + chalk.green('active') + ' (verifying fixes in Docker container)'));
141
- }
142
-
143
- // ── Run the scan ─────────────────────────────────────────────────────────
144
- const scanSpinner = ora({ text: 'Scanning for issues...', color: 'cyan' }).start();
145
- let scanResult;
146
- try {
147
- scanResult = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
148
- } catch (err) {
149
- scanSpinner.fail('Scan failed');
150
- output.error(err.message);
151
- process.exit(1);
152
- }
153
- scanSpinner.stop();
154
-
155
- // ── Filter findings ──────────────────────────────────────────────────────
156
- const minSev = options.severity || 'low';
157
- const minRank = SEV_RANK[minSev] ?? 1;
158
-
159
- const findings = (scanResult.findings ?? []).filter(f => {
160
- if (!f.file) return false;
161
- if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
162
- const rel = f.file.replace(/\\/g, '/');
163
- if (NEVER_EDIT.some(p => p.test(rel))) return false;
164
- const abs = path.resolve(root, f.file);
165
- return fs.existsSync(abs);
166
- });
167
-
168
- if (findings.length === 0) {
169
- output.success('No fixable findings at the requested severity.');
170
- console.log();
171
- return;
172
- }
173
-
174
- // ── Group by file ────────────────────────────────────────────────────────
175
- const byFile = new Map();
176
- for (const f of findings) {
177
- const list = byFile.get(f.file) ?? [];
178
- list.push(f);
179
- byFile.set(f.file, list);
180
- }
181
-
182
- console.log(chalk.cyan(` Found ${findings.length} fixable finding(s) across ${byFile.size} file(s)`));
183
- console.log();
184
-
185
- // ── Fix loop ─────────────────────────────────────────────────────────────
186
- const applied = []; // { file, plan, verified }
187
- const skipped = []; // { file, findings, reason }
188
- let stopped = false;
189
- let i = 0;
190
-
191
- for (const [filePath, fileFindings] of byFile) {
192
- i++;
193
- if (stopped) break;
194
-
195
- const idx = `[${i}/${byFile.size}]`;
196
- console.log();
197
- console.log(chalk.bold(` ${idx} ${chalk.cyan(filePath)} ${chalk.gray(`— ${fileFindings.length} finding(s)`)}`));
198
- for (const f of fileFindings) {
199
- console.log(` ${severityLabel(f.severity)} ${f.title}${f.line ? chalk.gray(` (line ${f.line})`) : ''}`);
200
- }
201
-
202
- // ── Plan → apply → verify ladder, with one evidence-fed retry ──────────
203
- const maxAttempts = options.maxAttempts || 2;
204
- let retryEvidence = '';
205
- let decision = null;
206
- let finalPlan = null;
207
- let finalVerified = null;
208
- let snapshots = []; // pre-fix file state, captured once on first attempt
209
- let ladderFailed = false; // verification failed with nothing resolved
210
-
211
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
212
- if (retryEvidence) {
213
- console.log(chalk.yellow(` Verification failed — retrying plan with failure evidence (attempt ${attempt}/${maxAttempts})`));
214
- }
215
-
216
- // Generate plan
217
- const planSpinner = ora({ text: retryEvidence ? 'Regenerating fix plan...' : 'Generating fix plan...', color: 'cyan', indent: 6 }).start();
218
- const planResult = await generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence);
219
- planSpinner.stop();
220
-
221
- if (!planResult.ok) {
222
- const detail = describePlanFailure(planResult);
223
- console.log(chalk.yellow(` ${detail.message}`));
224
- logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), ...planResult });
225
- skipped.push({ file: filePath, findings: fileFindings, reason: detail.reason });
226
- finalPlan = null;
227
- break;
228
- }
229
- const plan = planResult.plan;
230
-
231
- // Validate (allows new safe files like .env.example)
232
- const validation = validatePlan(root, plan);
233
- if (!validation.ok) {
234
- console.log(chalk.yellow(` Plan invalid: ${validation.reason}`));
235
- logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), reason: 'validation-rejected', detail: validation.reason, plan });
236
- skipped.push({ file: filePath, findings: fileFindings, reason: `plan-invalid: ${validation.reason}` });
237
- finalPlan = null;
238
- break;
239
- }
240
-
241
- // Show plan
242
- printPlan(plan, root);
243
-
244
- if (options.planOnly) {
245
- console.log(chalk.gray(' (plan-only mode — not applying)'));
246
- finalPlan = null;
247
- break;
248
- }
249
-
250
- // Decision (prompt once; retries reuse the first decision)
251
- if (decision === null) {
252
- const risk = (plan.risk || 'medium').toLowerCase();
253
- if (options.yolo || options.ci) {
254
- decision = 'a';
255
- console.log(chalk.gray(` (${options.ci ? 'ci' : 'yolo'}: auto-accepting)`));
256
- } else if (options.autoLow && risk === 'low') {
257
- decision = 'a';
258
- console.log(chalk.gray(' (auto-low: low-risk, auto-accepting)'));
259
- } else {
260
- decision = await promptDecision(plan, root);
261
- }
262
- }
263
-
264
- if (decision === 'q' || decision === 'quit') {
265
- console.log(chalk.gray(' Stopping.'));
266
- stopped = true;
267
- finalPlan = null;
268
- break;
269
- }
270
- if (!['a', 'accept', 'y', 'yes'].includes(decision)) {
271
- skipped.push({ file: filePath, findings: fileFindings, reason: 'user-skipped' });
272
- finalPlan = null;
273
- break;
274
- }
275
-
276
- // Apply — snapshot file state once (first attempt) so a failed ladder
277
- // can restore the repo to its pre-fix state.
278
- let applyErr = null;
279
- if (attempt === 1) {
280
- snapshots = plan.files.map(fc => {
281
- const abs = path.resolve(root, fc.path);
282
- return { abs, existed: fs.existsSync(abs), content: fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null };
283
- });
284
- }
285
- try {
286
- for (const fileChange of plan.files) {
287
- applyEdit(root, fileChange);
288
- }
289
- } catch (err) {
290
- applyErr = err.message;
291
- }
292
-
293
- if (applyErr) {
294
- console.log(chalk.red(` Apply failed: ${applyErr}`));
295
- skipped.push({ file: filePath, findings: fileFindings, reason: `apply-failed: ${applyErr}` });
296
- finalPlan = null;
297
- break;
298
- }
299
-
300
- // Verify — tiered ladder (build → tests → re-scan)
301
- const verifySpinner = ora({ text: 'Verifying (build → tests → re-scan)...', color: 'cyan', indent: 6 }).start();
302
- const verified = await verifyFile(root, filePath, fileFindings, options);
303
- finalPlan = plan;
304
- finalVerified = verified;
305
-
306
- if (verified.allResolved) {
307
- const ranTiers = (verified.tiers || []).map(t => t.tier);
308
- verifySpinner.succeed(chalk.green(`Fix verified — ${fileFindings.length} finding(s) resolved (${[...ranTiers, 're-scan'].join(' → ')})`));
309
- ladderFailed = false;
310
- break;
311
- }
312
-
313
- if (verified.someResolved) {
314
- verifySpinner.warn(chalk.yellow(`Partial: ${verified.resolvedCount}/${fileFindings.length} resolved`));
315
- ladderFailed = false;
316
- } else {
317
- verifySpinner.warn(chalk.yellow(`Verification failed at tier "${verified.failedTier}"`));
318
- ladderFailed = true;
319
- }
320
-
321
- // Evidence-fed retry
322
- if (attempt < maxAttempts && verified.evidence) {
323
- retryEvidence = verified.evidence;
324
- continue;
325
- }
326
- break;
327
- }
328
-
329
- // Revert failed fixes (nothing resolved) so the repo stays consistent —
330
- // even when the retry plan itself failed to generate.
331
- if (ladderFailed && snapshots.length > 0) {
332
- for (const s of snapshots) {
333
- try {
334
- if (s.existed) fs.writeFileSync(s.abs, s.content, 'utf8');
335
- else if (fs.existsSync(s.abs)) fs.unlinkSync(s.abs);
336
- } catch { /* best-effort restore */ }
337
- }
338
- console.log(chalk.yellow(' Reverted failed fix — file(s) restored to pre-fix state.'));
339
- logFailure(root, {
340
- timestamp: new Date().toISOString(),
341
- file: filePath,
342
- findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })),
343
- reason: 'verification-failed',
344
- detail: finalVerified?.evidence || 'verification failed',
345
- plan: finalPlan,
346
- });
347
- skipped.push({ file: filePath, findings: fileFindings, reason: `verification-failed: ${finalVerified?.failedTier || 'unknown'}` });
348
- continue;
349
- }
350
-
351
- if (!finalPlan || !finalVerified) continue;
352
-
353
- // Per-fix commit (if branch isolation in use)
354
- const written = finalPlan.files.map(fc => path.resolve(root, fc.path));
355
- let commitHash = null;
356
- if (branchCreated) {
357
- try {
358
- execFileSync('git', ['add', '--', ...written], { cwd: root, stdio: 'pipe' });
359
- const titles = fileFindings.slice(0, 3).map(f => f.title).join(', ');
360
- const more = fileFindings.length > 3 ? ` (+${fileFindings.length - 3} more)` : '';
361
- const msg = `fix(security): ${filePath} — ${titles}${more}`;
362
- execFileSync('git', ['commit', '-m', msg], { cwd: root, stdio: 'pipe' });
363
- commitHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf-8' }).trim();
364
- } catch {
365
- // commit failed — most likely nothing staged because edits were no-ops
366
- }
367
- }
368
-
369
- // Log
370
- logFix(root, {
371
- timestamp: new Date().toISOString(),
372
- file: filePath,
373
- findings: fileFindings.map(f => ({ title: f.title, line: f.line, severity: f.severity, rule: f.rule })),
374
- plan: finalPlan,
375
- verified: finalVerified.allResolved,
376
- verificationClass: finalVerified.verification,
377
- verificationTiers: (finalVerified.tiers || []).map(t => ({ tier: t.tier, command: t.command, ok: t.ok })),
378
- branch: branchCreated,
379
- commitHash,
380
- });
381
-
382
- applied.push({ file: filePath, plan: finalPlan, verified: finalVerified });
383
- }
384
-
385
- // ── Final report ─────────────────────────────────────────────────────────
386
- console.log();
387
- console.log();
388
- output.header('Summary');
389
- console.log();
390
- console.log(` ${chalk.green('Applied:')} ${applied.length} file(s)`);
391
- console.log(` ${chalk.gray('Skipped:')} ${skipped.length} file(s)`);
392
-
393
- if (applied.length > 0) {
394
- console.log();
395
- console.log(chalk.gray(' Applied:'));
396
- for (const a of applied) {
397
- const mark = a.verified.allResolved ? chalk.green('✓') : chalk.yellow('?');
398
- console.log(` ${mark} ${a.file}`);
399
- }
400
- console.log();
401
- console.log(chalk.gray(` Audit log: ${path.join(FIX_LOG_DIR, FIX_LOG_FILE)}`));
402
- if (branchCreated) {
403
- console.log(chalk.gray(` Branch: ${chalk.cyan(branchCreated)}`));
404
- console.log(chalk.gray(` Switch back: git checkout ${initialBranch}`));
405
- } else {
406
- console.log(chalk.gray(' Review: git diff'));
407
- console.log(chalk.gray(' Undo last: praxis undo'));
408
- }
409
- }
410
-
411
- // ── PR autopilot ─────────────────────────────────────────────────────────
412
- if (options.pr && applied.length > 0 && branchCreated) {
413
- console.log();
414
- await openPullRequest(root, branchCreated, applied);
415
- } else if (options.pr && !branchCreated) {
416
- console.log();
417
- console.log(chalk.yellow(' --pr requires --branch. Skipping PR creation.'));
418
- }
419
-
420
- if (skipped.length > 0 && applied.length === 0) {
421
- console.log();
422
- console.log(chalk.gray(' Tip: try a different provider with --provider, or run with --plan-only'));
423
- console.log(chalk.gray(' to inspect what would change.'));
424
- }
425
-
426
- console.log();
427
- }
428
-
429
- // =============================================================================
430
- // PLAN GENERATION
431
- // =============================================================================
432
-
433
- // Generate a fix plan for a file. Returns either:
434
- // { ok: true, plan } — plan generated successfully
435
- // { ok: false, reason, raw, error } — failed; reason is one of:
436
- // 'file-read-failed' | 'provider-error' | 'parse-error' |
437
- // 'llm-declined' | 'empty-response'
438
- // Caller decides what to do with the failure (log, persist, skip).
439
- async function generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence = '') {
440
- const abs = path.resolve(root, filePath);
441
- let content;
442
- try {
443
- content = fs.readFileSync(abs, 'utf8');
444
- } catch (err) {
445
- return { ok: false, reason: 'file-read-failed', error: err.message };
446
- }
447
-
448
- const fileForPrompt = windowFileContent(content, fileFindings);
449
-
450
- const findingsBlock = fileFindings.map((f, i) => `
451
- ${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ''}
452
- Rule: ${f.rule ?? 'N/A'}
453
- Description: ${f.description ?? 'N/A'}${f.fix ? `\n Suggested fix: ${f.fix}` : ''}
454
- `).join('');
455
-
456
- const systemPrompt = 'You are a security engineer. Produce precise code edits as structured JSON only. Never include prose, markdown, or code fences. Output a single JSON object.';
457
-
458
- const userPrompt = `Fix all of these security findings in a single file by producing one coordinated plan.
459
-
460
- FILE: ${filePath}
461
-
462
- FINDINGS (${fileFindings.length}):
463
- ${findingsBlock}
464
-
465
- CURRENT FILE CONTENT:
466
- \`\`\`
467
- ${fileForPrompt}
468
- \`\`\`
469
-
470
- OUTPUT this exact JSON shape:
471
- {
472
- "summary": "one short sentence describing what you'll do across all findings",
473
- "files": [
474
- {
475
- "path": "${filePath}",
476
- "edits": [
477
- { "find": "EXACT verbatim substring", "replace": "new string", "reason": "addresses finding N" }
478
- ]
479
- }
480
- ],
481
- "risk": "low"
482
- }
483
-
484
- You MAY also include companion file changes (only these are allowed):
485
- - .env.example — add placeholders for any secrets you moved to env vars
486
- - .gitignore — add patterns for files that should not be committed
487
-
488
- For companion files, use this shape (no "find" needed):
489
- { "path": ".env.example", "create": true, "content": "FULL FILE CONTENT" }
490
- or to append:
491
- { "path": ".gitignore", "append": "PATTERN_TO_ADD\\n" }
492
-
493
- RULES:
494
- - Each "find" string must appear EXACTLY ONCE in the file. Include enough context (3+ lines) for uniqueness.
495
- - "replace" must be the corrected code. Preserve indentation and surrounding style.
496
- - Address each finding listed above with at least one edit (or explain in summary why a finding can't be mechanically fixed).
497
- - GREP FOR SIBLING CALL SITES: search the file (and clearly-related helper files) for every other instance of the same dangerous pattern and fix all of them, not just the flagged line. A fix that leaves variant call sites behind fails verification.
498
- - REGRESSION TEST: if this project has a test suite, include a test in your edits that fails before your fix and passes after it.
499
- - Risk: "low" = mechanical, "medium" = behavior change, "high" = architectural. Use "high" sparingly.
500
- - If you cannot produce a precise mechanical plan, return {"summary":"requires manual review","files":[],"risk":"high"}
501
- - JSON only. No prose. No code fences.${retryEvidence ? `
502
-
503
- PREVIOUS ATTEMPT FEEDBACK — your earlier plan was applied but automated verification failed:
504
-
505
- ${retryEvidence}
506
-
507
- Produce a corrected plan against the CURRENT file content below. Diagnose the root cause of the
508
- verification failure (build break, failing test, or finding still present) and fix it.` : ''}`;
509
-
510
- let response;
511
- try {
512
- response = await provider.complete(systemPrompt, userPrompt, {
513
- maxTokens: 3000,
514
- jsonMode: true,
515
- });
516
- } catch (err) {
517
- return { ok: false, reason: 'provider-error', error: err.message };
518
- }
519
-
520
- if (!response || !response.trim()) {
521
- return { ok: false, reason: 'empty-response', raw: response ?? '' };
522
- }
523
-
524
- const plan = parseJsonLoose(response);
525
- if (!plan) {
526
- return { ok: false, reason: 'parse-error', raw: response };
527
- }
528
- if (!Array.isArray(plan.files) || plan.files.length === 0) {
529
- // The LLM returned valid JSON but explicitly declined to produce edits
530
- // (typically with summary like "requires manual review"). This is a
531
- // legitimate "I don't know" — not a bug.
532
- return { ok: false, reason: 'llm-declined', raw: response, plan };
533
- }
534
-
535
- return { ok: true, plan };
536
- }
537
-
538
- function parseJsonLoose(response) {
539
- if (!response) return null;
540
- const cleaned = response.trim()
541
- .replace(/^```(?:json)?\s*/i, '')
542
- .replace(/```\s*$/i, '')
543
- .trim();
544
- try {
545
- return JSON.parse(cleaned);
546
- } catch {
547
- const m = cleaned.match(/\{[\s\S]*\}/);
548
- if (m) {
549
- try { return JSON.parse(m[0]); } catch { return null; }
550
- }
551
- return null;
552
- }
553
- }
554
-
555
- function windowFileContent(content, fileFindings) {
556
- if (content.length <= 8000) return content;
557
- if (!Array.isArray(fileFindings) || fileFindings.length === 0) {
558
- return content.slice(0, 8000);
559
- }
560
-
561
- const lines = content.split('\n');
562
- const RADIUS = 40;
563
-
564
- const targetLines = [...new Set(fileFindings.map(f => f.line).filter(Boolean))].sort((a, b) => a - b);
565
- if (targetLines.length === 0) return content.slice(0, 8000);
566
-
567
- const ranges = [];
568
- for (const line of targetLines) {
569
- const start = Math.max(0, line - 1 - RADIUS);
570
- const end = Math.min(lines.length - 1, line - 1 + RADIUS);
571
- if (ranges.length === 0) {
572
- ranges.push({ start, end });
573
- } else {
574
- const last = ranges[ranges.length - 1];
575
- if (start <= last.end + 5) {
576
- last.end = Math.max(last.end, end);
577
- } else {
578
- ranges.push({ start, end });
579
- }
580
- }
581
- }
582
-
583
- const result = [];
584
- let lastEnd = 0;
585
- for (const r of ranges) {
586
- if (r.start > lastEnd) {
587
- result.push(`// ... [lines ${lastEnd + 1}-${r.start} truncated] ...`);
588
- }
589
- result.push(lines.slice(r.start, r.end + 1).join('\n'));
590
- lastEnd = r.end + 1;
591
- }
592
- if (lastEnd < lines.length) {
593
- result.push(`// ... [lines ${lastEnd + 1}-${lines.length} truncated] ...`);
594
- }
595
- return result.join('\n');
596
- }
597
-
598
- // =============================================================================
599
- // PLAN VALIDATION
600
- // =============================================================================
601
-
602
- function validatePlan(root, plan) {
603
- if (!Array.isArray(plan.files) || plan.files.length === 0) {
604
- return { ok: false, reason: 'no files in plan' };
605
- }
606
-
607
- for (const f of plan.files) {
608
- if (!f.path) return { ok: false, reason: 'file entry missing path' };
609
-
610
- const rel = f.path.replace(/\\/g, '/');
611
- const isSafeNew = SAFE_NEW_FILES.some(p => p.test(rel));
612
-
613
- // Block protected paths unless this is a known-safe companion file
614
- if (!isSafeNew && NEVER_EDIT.some(p => p.test(rel))) {
615
- return { ok: false, reason: `protected path: ${f.path}` };
616
- }
617
-
618
- const abs = path.resolve(root, f.path);
619
- const exists = fs.existsSync(abs);
620
-
621
- // Companion file forms (create / append)
622
- if (f.create || f.append !== undefined) {
623
- if (!exists && !isSafeNew) {
624
- return { ok: false, reason: `cannot create new file at ${f.path}` };
625
- }
626
- if (f.create && typeof f.content !== 'string') {
627
- return { ok: false, reason: 'create entry missing content' };
628
- }
629
- if (f.append && typeof f.append !== 'string') {
630
- return { ok: false, reason: 'append must be a string' };
631
- }
632
- continue;
633
- }
634
-
635
- // Standard edit form
636
- if (!exists) return { ok: false, reason: `file not found: ${f.path}` };
637
- if (!Array.isArray(f.edits) || f.edits.length === 0) {
638
- return { ok: false, reason: `no edits for ${f.path}` };
639
- }
640
-
641
- const content = fs.readFileSync(abs, 'utf8');
642
- for (const e of f.edits) {
643
- if (typeof e.find !== 'string' || typeof e.replace !== 'string') {
644
- return { ok: false, reason: 'edit missing find/replace' };
645
- }
646
- if (e.find === e.replace) {
647
- return { ok: false, reason: 'edit is a no-op' };
648
- }
649
- const match = locateFindString(content, e.find);
650
- if (match.kind === 'missing') {
651
- return { ok: false, reason: `find string not present in ${f.path}` };
652
- }
653
- if (match.kind === 'ambiguous') {
654
- return { ok: false, reason: `find string is ambiguous (${match.count} matches) in ${f.path}` };
655
- }
656
- // Annotate the edit with the resolved match for use during apply
657
- e._resolvedFind = match.matched;
658
- }
659
- }
660
- return { ok: true };
661
- }
662
-
663
- // Try exact match first, then whitespace-normalized match if exact misses.
664
- // Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }
665
- function locateFindString(haystack, needle) {
666
- const exact = countOccurrences(haystack, needle);
667
- if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };
668
- if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };
669
-
670
- // Whitespace-tolerant fallback: collapse whitespace runs and try again
671
- const norm = (s) => s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
672
- const needleNorm = norm(needle);
673
- if (!needleNorm) return { kind: 'missing', matched: null, count: 0 };
674
-
675
- // Walk the haystack and check if any window normalizes to the same string
676
- // To keep this cheap, only attempt when needle has at least one newline (likely a code block)
677
- const lines = haystack.split('\n');
678
- const needleLines = needleNorm.split('\n').length;
679
- let foundIdx = -1;
680
- let foundCount = 0;
681
- for (let i = 0; i + needleLines <= lines.length; i++) {
682
- const window = lines.slice(i, i + needleLines).join('\n');
683
- if (norm(window) === needleNorm) {
684
- foundIdx = i;
685
- foundCount++;
686
- if (foundCount > 1) break;
687
- }
688
- }
689
- if (foundCount === 1) {
690
- const matched = lines.slice(foundIdx, foundIdx + needleLines).join('\n');
691
- return { kind: 'unique', matched, count: 1 };
692
- }
693
- if (foundCount > 1) return { kind: 'ambiguous', matched: null, count: foundCount };
694
- return { kind: 'missing', matched: null, count: 0 };
695
- }
696
-
697
- function countOccurrences(haystack, needle) {
698
- if (!needle) return 0;
699
- let count = 0, idx = 0;
700
- while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
701
- return count;
702
- }
703
-
704
- // =============================================================================
705
- // PRINTING
706
- // =============================================================================
707
-
708
- function printPlan(plan, _root) {
709
- console.log();
710
- console.log(chalk.bold(' Plan:'));
711
- console.log(chalk.white(` ${plan.summary || '(no summary)'}`));
712
- if (plan.risk) {
713
- const riskColor = plan.risk === 'low' ? chalk.green : plan.risk === 'medium' ? chalk.yellow : chalk.red;
714
- console.log(` Risk: ${riskColor(plan.risk)}`);
715
- }
716
- console.log();
717
-
718
- for (const f of plan.files) {
719
- if (f.create) {
720
- console.log(chalk.bold(` ${chalk.green('+ ')}${f.path} ${chalk.gray('(new file)')}`));
721
- printNewFilePreview(f.content);
722
- continue;
723
- }
724
- if (f.append !== undefined) {
725
- console.log(chalk.bold(` ${f.path} ${chalk.gray('(append)')}`));
726
- for (const l of f.append.split('\n')) {
727
- if (l) console.log(chalk.green(` + ${l}`));
728
- }
729
- continue;
730
- }
731
- console.log(chalk.bold(` ${f.path}`));
732
- for (const e of f.edits) {
733
- console.log(chalk.gray(` — ${e.reason || 'edit'}`));
734
- printDiff(e._resolvedFind || e.find, e.replace);
735
- }
736
- }
737
- console.log();
738
- }
739
-
740
- function printNewFilePreview(content) {
741
- const lines = content.split('\n');
742
- const max = 6;
743
- const shown = lines.slice(0, max);
744
- for (const l of shown) console.log(chalk.green(` + ${l}`));
745
- if (lines.length > max) {
746
- console.log(chalk.gray(` … +${lines.length - max} more line(s)`));
747
- }
748
- }
749
-
750
- function printDiff(oldStr, newStr) {
751
- const oldLines = oldStr.split('\n');
752
- const newLines = newStr.split('\n');
753
- for (const l of oldLines) console.log(chalk.red(` - ${l}`));
754
- for (const l of newLines) console.log(chalk.green(` + ${l}`));
755
- }
756
-
757
- function severityLabel(sev) {
758
- switch (sev) {
759
- case 'critical': return chalk.red.bold('[CRITICAL]');
760
- case 'high': return chalk.red('[HIGH]');
761
- case 'medium': return chalk.yellow('[MEDIUM]');
762
- case 'low': return chalk.blue('[LOW]');
763
- default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
764
- }
765
- }
766
-
767
- // =============================================================================
768
- // APPLY
769
- // =============================================================================
770
-
771
- function applyEdit(root, fileChange) {
772
- const abs = path.resolve(root, fileChange.path);
773
-
774
- if (fileChange.create) {
775
- fs.mkdirSync(path.dirname(abs), { recursive: true });
776
- fs.writeFileSync(abs, fileChange.content, 'utf8');
777
- return;
778
- }
779
-
780
- if (fileChange.append !== undefined) {
781
- fs.mkdirSync(path.dirname(abs), { recursive: true });
782
- const existing = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
783
- // Avoid duplicate appends
784
- if (existing.includes(fileChange.append.trim())) return;
785
- const sep = existing && !existing.endsWith('\n') ? '\n' : '';
786
- fs.writeFileSync(abs, existing + sep + fileChange.append, 'utf8');
787
- return;
788
- }
789
-
790
- let content = fs.readFileSync(abs, 'utf8');
791
- for (const e of fileChange.edits) {
792
- const find = e._resolvedFind || e.find;
793
- if (!content.includes(find)) {
794
- throw new Error(`find string drifted in ${fileChange.path} (file changed mid-plan)`);
795
- }
796
- content = content.replace(find, e.replace);
797
- }
798
- fs.writeFileSync(abs, content, 'utf8');
799
- }
800
-
801
- // =============================================================================
802
- // VERIFY — tiered ladder (P-IMP-032)
803
- // =============================================================================
804
- // Executable oracles only; no tier is decided by model judgment:
805
- // Tier 1 build/lint — the project's own build (or lint) must still pass
806
- // Tier 2 test suite — the project's test command must still pass
807
- // Tier 3 re-scan — original findings must be gone from the fixed file
808
- // Failing tier evidence is returned so the caller can feed it into a retry.
809
-
810
- function isDockerAvailable() {
811
- try {
812
- execFileSync('docker', ['info'], { stdio: 'ignore' });
813
- return true;
814
- } catch {
815
- return false;
816
- }
817
- }
818
-
819
- function detectBuildCommand(root) {
820
- try {
821
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
822
- if (typeof pkg.scripts?.build === 'string') return { label: 'build', command: ['npm', 'run', 'build'] };
823
- if (typeof pkg.scripts?.lint === 'string') return { label: 'lint', command: ['npm', 'run', 'lint'] };
824
- } catch { /* no package.json */ }
825
- if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'build', command: ['cargo', 'build'] };
826
- if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'build', command: ['go', 'build', './...'] };
827
- if (fs.existsSync(path.join(root, 'Makefile'))) return { label: 'build', command: ['make'] };
828
- return null;
829
- }
830
-
831
- function detectTestCommand(root) {
832
- try {
833
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
834
- if (typeof pkg.scripts?.test === 'string') return { label: 'test', command: ['npm', 'test'] };
835
- } catch { /* no package.json */ }
836
- if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'test', command: ['cargo', 'test'] };
837
- if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'test', command: ['go', 'test', './...'] };
838
- if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'pytest.ini'))) return { label: 'test', command: ['pytest'] };
839
- return null;
840
- }
841
-
842
- function runProjectCommand(root, command, timeoutMs = 300000) {
843
- const [cmd0, ...args] = command;
844
- try {
845
- let stdout;
846
- if (process.platform === 'win32' && /^npm$/.test(cmd0)) {
847
- // npm is a .cmd shim on Windows — spawnSync of .cmd files returns EINVAL
848
- // without a shell. Commands are fixed internal strings (no user input).
849
- const line = `npm.cmd ${args.map(a => `"${a}"`).join(' ')}`;
850
- stdout = execFileSync(line, {
851
- cwd: root,
852
- shell: true,
853
- encoding: 'utf-8',
854
- timeout: timeoutMs,
855
- maxBuffer: 10 * 1024 * 1024,
856
- stdio: ['ignore', 'pipe', 'pipe'],
857
- });
858
- } else {
859
- stdout = execFileSync(cmd0, args, {
860
- cwd: root,
861
- encoding: 'utf-8',
862
- timeout: timeoutMs,
863
- maxBuffer: 10 * 1024 * 1024,
864
- stdio: ['ignore', 'pipe', 'pipe'],
865
- });
866
- }
867
- return { ok: true, exitCode: 0, output: String(stdout).slice(-800) };
868
- } catch (err) {
869
- const output = String((err.stdout || '') + '\n' + (err.stderr || '')).slice(-800);
870
- const timedOut = err.killed || /ETIMEDOUT|timed out/i.test(err.message);
871
- return { ok: false, exitCode: err.status ?? -1, output, timedOut };
872
- }
873
- }
874
-
875
- async function rescanForFile(root, filePath, options) {
876
- if (options.sandbox && isDockerAvailable()) {
877
- const stdout = execFileSync('docker', [
878
- 'run', '--rm',
879
- '-v', `${root}:/workspace`,
880
- '-v', `${praxisDir}:/opt/praxis:ro`,
881
- '-w', '/workspace',
882
- 'node:18-alpine',
883
- 'node', '/opt/praxis/cli/bin/praxis.js', 'scan', '.', '--json'
884
- ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
885
-
886
- const jsonStart = stdout.search(/^\s*\{/m);
887
- if (jsonStart === -1) return { findings: [] };
888
- try { return JSON.parse(stdout.slice(jsonStart)); } catch { return { findings: [] }; }
889
- }
890
- return auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
891
- }
892
-
893
- export async function verifyFile(root, filePath, originalFindings, options = {}) {
894
- const tiers = [];
895
-
896
- try {
897
- // Tier 0 — AST syntax verification (P-IMP-010)
898
- const ext = path.extname(filePath).toLowerCase();
899
- if (['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py'].includes(ext)) {
900
- try {
901
- const fileContent = fs.readFileSync(path.resolve(root, filePath), 'utf8');
902
- const parsed = ASTParser.parse(fileContent, filePath);
903
- const hasValidAST = parsed && parsed.ast && Array.isArray(parsed.ast.body);
904
- tiers.push({ tier: 'ast_syntax', ok: hasValidAST });
905
- if (!hasValidAST) {
906
- return {
907
- allResolved: false, someResolved: false, resolvedCount: 0,
908
- verification: 'failed', failedTier: 'ast_syntax',
909
- evidence: `AST syntax verification failed on fixed file: ${filePath}`,
910
- tiers,
911
- };
912
- }
913
- } catch (err) {
914
- return {
915
- allResolved: false, someResolved: false, resolvedCount: 0,
916
- verification: 'failed', failedTier: 'ast_syntax',
917
- evidence: `AST syntax verification failed: ${err.message}`,
918
- tiers,
919
- };
920
- }
921
- }
922
-
923
- // Tier 1 — build/lint
924
- const build = detectBuildCommand(root);
925
- if (build) {
926
- const r = runProjectCommand(root, build.command);
927
- tiers.push({ tier: 'build', command: build.command.join(' '), ok: r.ok, output: r.output });
928
- if (!r.ok) {
929
- return {
930
- allResolved: false, someResolved: false, resolvedCount: 0,
931
- verification: 'failed', failedTier: 'build',
932
- evidence: `Build failed (${build.label}): ${r.output}`.slice(0, 1200),
933
- tiers,
934
- };
935
- }
936
- }
937
-
938
- // Tier 2 — test suite
939
- const test = detectTestCommand(root);
940
- if (test) {
941
- const r = runProjectCommand(root, test.command, 600000);
942
- tiers.push({ tier: 'test', command: test.command.join(' '), ok: r.ok, output: r.output });
943
- if (!r.ok) {
944
- return {
945
- allResolved: false, someResolved: false, resolvedCount: 0,
946
- verification: 'failed', failedTier: 'test',
947
- evidence: `Tests failed (${test.label}): ${r.output}`.slice(0, 1200),
948
- tiers,
949
- };
950
- }
951
- }
952
-
953
- // Tier 3 — re-scan: original findings must be gone from the fixed file
954
- const result = await rescanForFile(root, filePath, options);
955
- const remaining = (result.findings ?? []).filter(f => {
956
- const fPath = path.resolve(root, f.file);
957
- const targetPath = path.resolve(root, filePath);
958
- return fPath === targetPath;
959
- });
960
-
961
- let resolvedCount = 0;
962
- const survivors = [];
963
- for (const orig of originalFindings) {
964
- const stillThere = remaining.some(f =>
965
- f.rule === orig.rule &&
966
- Math.abs((f.line ?? 0) - (orig.line ?? 0)) <= 2,
967
- );
968
- if (!stillThere) resolvedCount++;
969
- else survivors.push(orig.rule);
970
- }
971
-
972
- const allResolved = resolvedCount === originalFindings.length;
973
- return {
974
- allResolved,
975
- someResolved: resolvedCount > 0,
976
- resolvedCount,
977
- verification: allResolved ? 'passed' : (resolvedCount > 0 ? 'partial' : 'failed'),
978
- failedTier: allResolved ? null : 'rescan',
979
- evidence: allResolved ? '' : `Re-scan still reports: ${survivors.join(', ')}`,
980
- tiers,
981
- };
982
- } catch (err) {
983
- return {
984
- allResolved: false, someResolved: false, resolvedCount: 0,
985
- verification: 'failed', failedTier: 'verify-error',
986
- evidence: err.message || 'verification crashed',
987
- tiers,
988
- };
989
- }
990
- }
991
-
992
- // =============================================================================
993
- // LOGGING
994
- // =============================================================================
995
-
996
- function logFix(root, entry) {
997
- const dir = path.join(root, FIX_LOG_DIR);
998
- const file = path.join(dir, FIX_LOG_FILE);
999
- fs.mkdirSync(dir, { recursive: true });
1000
- fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
1001
- }
1002
-
1003
- // Append a structured record for every plan that didn't produce an apply.
1004
- // Useful for debugging regressions (the LLM said X, we rejected because Y).
1005
- function logFailure(root, entry) {
1006
- const dir = path.join(root, FIX_LOG_DIR);
1007
- const file = path.join(dir, 'failures.jsonl');
1008
- fs.mkdirSync(dir, { recursive: true });
1009
- // Truncate raw response to keep the log readable
1010
- const truncated = { ...entry };
1011
- if (typeof truncated.raw === 'string' && truncated.raw.length > 4000) {
1012
- truncated.raw = truncated.raw.slice(0, 4000) + '... [truncated]';
1013
- }
1014
- fs.appendFileSync(file, JSON.stringify(truncated) + '\n', 'utf8');
1015
- }
1016
-
1017
- // Turn a structured plan failure into a user-facing one-liner.
1018
- function describePlanFailure(failure) {
1019
- switch (failure.reason) {
1020
- case 'file-read-failed':
1021
- return { message: `Could not read source file: ${failure.error}`, reason: 'file-read-failed' };
1022
- case 'provider-error':
1023
- return { message: `LLM provider error — ${failure.error}`, reason: 'provider-error' };
1024
- case 'empty-response':
1025
- return { message: 'LLM returned an empty response (try again, or switch provider).', reason: 'empty-response' };
1026
- case 'parse-error':
1027
- return { message: 'LLM returned unparseable JSON — saved to .praxis/failures.jsonl for inspection.', reason: 'parse-error' };
1028
- case 'llm-declined': {
1029
- const summary = failure.plan?.summary;
1030
- return { message: summary
1031
- ? `LLM declined to fix this file — ${summary}`
1032
- : 'LLM declined to fix this file (returned files=[] — needs manual review).',
1033
- reason: 'llm-declined',
1034
- };
1035
- }
1036
- default:
1037
- return { message: `Plan failed: ${failure.reason}`, reason: failure.reason };
1038
- }
1039
- }
1040
-
1041
- // =============================================================================
1042
- // PR AUTOPILOT
1043
- // =============================================================================
1044
-
1045
- async function openPullRequest(root, branch, applied) {
1046
- const ghAvailable = (() => {
1047
- try { execFileSync('gh', ['--version'], { stdio: 'pipe' }); return true; }
1048
- catch { return false; }
1049
- })();
1050
- if (!ghAvailable) {
1051
- console.log(chalk.yellow(' gh CLI not found. Install from https://cli.github.com to enable --pr.'));
1052
- return;
1053
- }
1054
-
1055
- // Push branch
1056
- console.log(chalk.gray(' Pushing branch...'));
1057
- try {
1058
- execFileSync('git', ['push', '-u', 'origin', branch], { cwd: root, stdio: 'pipe' });
1059
- } catch (err) {
1060
- console.log(chalk.red(` Push failed: ${err.message}`));
1061
- return;
1062
- }
1063
-
1064
- // Build PR body
1065
- const totalFindings = applied.reduce((n, a) => n + (a.plan.files?.[0]?.edits?.length ?? 0), 0);
1066
- const body = [
1067
- '## Praxis — Security Fixes',
1068
- '',
1069
- `Applied ${applied.length} file(s) of fixes (${totalFindings} edit(s)) generated and verified by the Praxis agent.`,
1070
- '',
1071
- '### Files changed',
1072
- ...applied.map(a => {
1073
- const mark = a.verified.allResolved ? '✓' : '⚠';
1074
- return `- ${mark} \`${a.file}\` — ${a.plan.summary || 'security fix'}`;
1075
- }),
1076
- '',
1077
- '### Notes',
1078
- '- Each fix was generated by an LLM and verified by re-scanning the file.',
1079
- '- Files marked ⚠ have residual findings; review carefully before merging.',
1080
- '- Full audit log: `.praxis/fixes.jsonl`',
1081
- '',
1082
- 'Generated by `praxis agent`.',
1083
- ].join('\n');
1084
-
1085
- const title = `Security fixes: ${applied.length} file(s)`;
1086
-
1087
- console.log(chalk.gray(' Opening PR...'));
1088
- let prUrl = null;
1089
- try {
1090
- prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
1091
- console.log(chalk.green(` PR opened: ${prUrl}`));
1092
- } catch (err) {
1093
- console.log(chalk.red(` PR creation failed: ${err.message}`));
1094
- return;
1095
- }
1096
-
1097
- // If we're running inside CI on a PR, leave a comment on the originating PR
1098
- // pointing at our fix PR. Detect from common GitHub Actions env vars.
1099
- const originPr = detectOriginPrNumber();
1100
- if (originPr) {
1101
- const note = [
1102
- `### 🛡️ Praxis Agent — fix PR opened`,
1103
- ``,
1104
- `The Praxis agent found fixable security issues triggered by this PR and opened **${prUrl}** with proposed fixes.`,
1105
- ``,
1106
- `**Files changed:** ${applied.length}`,
1107
- `**Total edits:** ${totalFindings}`,
1108
- ``,
1109
- `Review the fix PR and merge if it looks good.`,
1110
- ].join('\n');
1111
- try {
1112
- execFileSync('gh', ['pr', 'comment', String(originPr), '--body', note], { cwd: root, stdio: 'pipe' });
1113
- console.log(chalk.green(` Commented on origin PR #${originPr}`));
1114
- } catch (err) {
1115
- console.log(chalk.yellow(` Could not comment on origin PR #${originPr}: ${err.message}`));
1116
- }
1117
- }
1118
- }
1119
-
1120
- // Detect the PR number that triggered this CI run. Supports GitHub Actions'
1121
- // pull_request and pull_request_target events. Returns null when not in CI
1122
- // or when the event isn't a PR event.
1123
- function detectOriginPrNumber() {
1124
- // Explicit override (handy for testing or non-GHA CI providers)
1125
- if (process.env.PRAXIS_ORIGIN_PR) return process.env.PRAXIS_ORIGIN_PR;
1126
-
1127
- // GitHub Actions: GITHUB_REF looks like "refs/pull/<n>/merge" or "refs/pull/<n>/head"
1128
- const ref = process.env.GITHUB_REF || '';
1129
- const m = ref.match(/^refs\/pull\/(\d+)\//);
1130
- if (m) return m[1];
1131
-
1132
- // GitHub Actions PR event payload also exposes the number via GITHUB_EVENT_PATH
1133
- const eventPath = process.env.GITHUB_EVENT_PATH;
1134
- if (eventPath && fs.existsSync(eventPath)) {
1135
- try {
1136
- const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
1137
- if (payload?.pull_request?.number) return String(payload.pull_request.number);
1138
- if (payload?.number) return String(payload.number);
1139
- } catch { /* malformed event payload */ }
1140
- }
1141
-
1142
- return null;
1143
- }
1144
-
1145
- // =============================================================================
1146
- // GIT
1147
- // =============================================================================
1148
-
1149
- function checkGitState(root) {
1150
- try {
1151
- execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'pipe' });
1152
- } catch {
1153
- return 'not-a-repo';
1154
- }
1155
- try {
1156
- const out = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
1157
- const meaningful = out.split('\n').filter(line => {
1158
- const path = line.slice(3).trim();
1159
- if (!path) return false;
1160
- if (path.startsWith('.praxis/')) return false;
1161
- if (path === 'praxis-report.html') return false;
1162
- return true;
1163
- });
1164
- return meaningful.length === 0 ? 'clean' : 'dirty';
1165
- } catch {
1166
- return 'clean';
1167
- }
1168
- }
1169
-
1170
- function getCurrentBranch(root) {
1171
- try {
1172
- return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();
1173
- } catch {
1174
- return null;
1175
- }
1176
- }
1177
-
1178
- // =============================================================================
1179
- // PROMPTS
1180
- // =============================================================================
1181
-
1182
- function prompt(question) {
1183
- return new Promise(resolve => {
1184
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1185
- rl.question(question, answer => { rl.close(); resolve(answer); });
1186
- });
1187
- }
1188
-
1189
- async function confirm(question) {
1190
- const a = (await prompt(`${question} [y/N] `)).trim().toLowerCase();
1191
- return a === 'y' || a === 'yes';
1192
- }
1193
-
1194
- // Plan decision prompt with [e]dit support: opens the plan in $EDITOR for the
1195
- // user to tweak, then re-validates. Loops until accept/skip/quit.
1196
- async function promptDecision(plan, root) {
1197
- while (true) {
1198
- const raw = (await prompt(chalk.cyan(' [a]ccept [s]kip [e]dit [q]uit > '))).trim().toLowerCase();
1199
- if (['a', 'accept', 'y', 'yes'].includes(raw)) return 'a';
1200
- if (['s', 'skip', 'n', 'no'].includes(raw)) return 's';
1201
- if (['q', 'quit'].includes(raw)) return 'q';
1202
- if (['e', 'edit'].includes(raw)) {
1203
- const edited = await editPlanInEditor(plan, root);
1204
- if (!edited) {
1205
- console.log(chalk.yellow(' Edit cancelled — keeping original plan.'));
1206
- } else {
1207
- // Mutate plan in place so the caller's reference picks up the changes
1208
- plan.summary = edited.summary;
1209
- plan.files = edited.files;
1210
- plan.risk = edited.risk;
1211
- // Re-validate then re-show
1212
- const validation = validatePlan(root, plan);
1213
- if (!validation.ok) {
1214
- console.log(chalk.red(` Edited plan invalid: ${validation.reason}`));
1215
- console.log(chalk.gray(' Returning to prompt — try editing again, or skip.'));
1216
- continue;
1217
- }
1218
- printPlan(plan, root);
1219
- }
1220
- // Loop back and re-prompt
1221
- continue;
1222
- }
1223
- console.log(chalk.gray(' Unknown choice. Type a, s, e, or q.'));
1224
- }
1225
- }
1226
-
1227
- async function editPlanInEditor(plan, root) {
1228
- const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
1229
- const tmpFile = path.join(root, '.praxis', `plan-edit-${Date.now()}.json`);
1230
- fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
1231
- // Strip _resolvedFind annotations before showing — they're internal
1232
- const exportable = JSON.parse(JSON.stringify(plan, (k, v) => k === '_resolvedFind' ? undefined : v));
1233
- fs.writeFileSync(tmpFile, JSON.stringify(exportable, null, 2), 'utf8');
1234
-
1235
- try {
1236
- execFileSync(editor, [tmpFile], { stdio: 'inherit' });
1237
- const updated = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
1238
- fs.unlinkSync(tmpFile);
1239
- return updated;
1240
- } catch (err) {
1241
- try { fs.unlinkSync(tmpFile); } catch { /* best effort */ }
1242
- console.log(chalk.red(` Editor failed: ${err.message}`));
1243
- return null;
1244
- }
1245
- }
1
+ /**
2
+ * Praxis Security Agent — Interactive Fix Loop
3
+ * ================================================
4
+ *
5
+ * Scans your codebase, then for each affected file:
6
+ * 1. Generates a precise multi-edit fix plan via LLM (one plan per file,
7
+ * addressing every finding in that file at once)
8
+ * 2. Shows you exactly what it will change (unified diff with line numbers)
9
+ * 3. Asks you to accept, skip, or quit
10
+ * 4. Applies the changes atomically
11
+ * 5. Re-scans to verify the findings are resolved
12
+ * 6. Logs every change to .praxis/fixes.jsonl
13
+ *
14
+ * USAGE:
15
+ * praxis agent [path] Interactive fix loop
16
+ * praxis agent . --plan-only Generate plans, never write
17
+ * praxis agent . --severity high Only fix high+ severity
18
+ * praxis agent . --branch fixes Create a branch, commit per file
19
+ * praxis agent . --pr After fixing, push and open a PR
20
+ * praxis agent . --provider deepseek-flash
21
+ *
22
+ * SAFETY:
23
+ * - Refuses to operate on a dirty git tree (use --allow-dirty to override)
24
+ * - Always shows a diff before any write
25
+ * - Re-scans after each batch to verify the fix
26
+ * - Plans may create new files (e.g., .env.example) but cannot edit
27
+ * .env, secrets, lockfiles, or build artifacts
28
+ * - Every applied change is logged for audit & undo (`praxis undo`)
29
+ */
30
+
31
+ import fs from 'fs';
32
+ import { fileURLToPath } from 'url';
33
+ import path, { dirname } from 'path';
34
+ import { createInterface } from 'readline';
35
+
36
+ const __filename = fileURLToPath(import.meta.url); // praxis-ignore — module's own path via import.meta.url, not user input
37
+ const __dirname = dirname(__filename);
38
+ const praxisDir = path.resolve(__dirname, '../..');
39
+ import { execFileSync } from 'child_process';
40
+ import chalk from 'chalk';
41
+ import ora from 'ora';
42
+ import { autoDetectProvider } from '../providers/llm-provider.js';
43
+ import { auditCommand } from './audit.js';
44
+ import { ASTParser } from '../core/ast/index.js';
45
+ import * as output from '../utils/output.js';
46
+ import { validatePlan, applyPlan, restoreSnapshots, isProtectedFixPath } from '../core/fix-plan.js';
47
+
48
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
49
+ const FIX_LOG_DIR = '.praxis';
50
+ const FIX_LOG_FILE = 'fixes.jsonl';
51
+
52
+ // =============================================================================
53
+ // MAIN
54
+ // =============================================================================
55
+
56
+ export async function agentFixCommand(targetPath = '.', options = {}) {
57
+ const root = path.resolve(targetPath);
58
+
59
+ if (!fs.existsSync(root)) {
60
+ output.error(`Path does not exist: ${root}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ console.log();
65
+ output.header('Praxis — Security Agent');
66
+ console.log(chalk.gray(' I will scan, plan each fix, ask before changing anything,'));
67
+ console.log(chalk.gray(' and verify the fix worked. You stay in control.'));
68
+ console.log();
69
+
70
+ // ── Git safety check ─────────────────────────────────────────────────────
71
+ const initialBranch = getCurrentBranch(root);
72
+ if (!options.allowDirty) {
73
+ const state = checkGitState(root);
74
+ if (state === 'not-a-repo') {
75
+ console.log(chalk.yellow(' Note: this is not a git repository.'));
76
+ console.log(chalk.gray(' Changes cannot be reverted automatically.'));
77
+ if (options.ci) {
78
+ console.log(chalk.gray(' CI mode active: automatically continuing.'));
79
+ } else {
80
+ const ok = await confirm(' Continue anyway?');
81
+ if (!ok) { console.log(chalk.gray(' Aborted.\n')); return; }
82
+ }
83
+ } else if (state === 'dirty') {
84
+ output.error('Working tree has uncommitted changes.');
85
+ console.log(chalk.gray(' Commit or stash first, or pass --allow-dirty.'));
86
+ process.exit(1);
87
+ }
88
+ }
89
+
90
+ // ── Optional branch isolation ────────────────────────────────────────────
91
+ let branchCreated = null;
92
+ if (options.branch) {
93
+ if (!initialBranch) {
94
+ console.log(chalk.yellow(' --branch requires a git repository. Skipping branch creation.'));
95
+ } else {
96
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
97
+ const branchName = options.branch === true
98
+ ? `praxis/fixes-${stamp}`
99
+ : String(options.branch);
100
+ try {
101
+ execFileSync('git', ['checkout', '-b', branchName], { cwd: root, stdio: 'pipe' });
102
+ branchCreated = branchName;
103
+ console.log(chalk.gray(` Branch: ${chalk.cyan(branchName)}`));
104
+ } catch (err) {
105
+ output.error(`Could not create branch ${branchName}: ${err.message}`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Load LLM provider ────────────────────────────────────────────────────
112
+ const provider = autoDetectProvider(root, {
113
+ provider: options.provider,
114
+ model: options.model,
115
+ think: options.think || false,
116
+ });
117
+ if (!provider) {
118
+ output.error('No LLM provider available.');
119
+ console.log(chalk.gray(' Set one of: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MOONSHOT_API_KEY, XAI_API_KEY'));
120
+ process.exit(1);
121
+ }
122
+ console.log(chalk.gray(` Provider: ${chalk.cyan(provider.name)}`));
123
+
124
+ if (options.sandbox) {
125
+ console.log(chalk.gray(' Sandbox: ' + chalk.green('active') + ' (verifying fixes in Docker container)'));
126
+ }
127
+
128
+ // ── Run the scan ─────────────────────────────────────────────────────────
129
+ const scanSpinner = ora({ text: 'Scanning for issues...', color: 'cyan' }).start();
130
+ let scanResult;
131
+ try {
132
+ scanResult = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
133
+ } catch (err) {
134
+ scanSpinner.fail('Scan failed');
135
+ output.error(err.message);
136
+ process.exit(1);
137
+ }
138
+ scanSpinner.stop();
139
+
140
+ // ── Filter findings ──────────────────────────────────────────────────────
141
+ const minSev = options.severity || 'low';
142
+ const minRank = SEV_RANK[minSev] ?? 1;
143
+
144
+ const findings = (scanResult.findings ?? []).filter(f => {
145
+ if (!f.file) return false;
146
+ if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
147
+ const rel = f.file.replace(/\\/g, '/');
148
+ if (isProtectedFixPath(rel)) return false;
149
+ const abs = path.resolve(root, f.file);
150
+ return fs.existsSync(abs);
151
+ });
152
+
153
+ if (findings.length === 0) {
154
+ output.success('No fixable findings at the requested severity.');
155
+ console.log();
156
+ return;
157
+ }
158
+
159
+ // ── Group by file ────────────────────────────────────────────────────────
160
+ const byFile = new Map();
161
+ for (const f of findings) {
162
+ const list = byFile.get(f.file) ?? [];
163
+ list.push(f);
164
+ byFile.set(f.file, list);
165
+ }
166
+
167
+ console.log(chalk.cyan(` Found ${findings.length} fixable finding(s) across ${byFile.size} file(s)`));
168
+ console.log();
169
+
170
+ // ── Fix loop ─────────────────────────────────────────────────────────────
171
+ const applied = []; // { file, plan, verified }
172
+ const skipped = []; // { file, findings, reason }
173
+ let stopped = false;
174
+ let i = 0;
175
+
176
+ for (const [filePath, fileFindings] of byFile) {
177
+ i++;
178
+ if (stopped) break;
179
+
180
+ const idx = `[${i}/${byFile.size}]`;
181
+ console.log();
182
+ console.log(chalk.bold(` ${idx} ${chalk.cyan(filePath)} ${chalk.gray(`— ${fileFindings.length} finding(s)`)}`));
183
+ for (const f of fileFindings) {
184
+ console.log(` ${severityLabel(f.severity)} ${f.title}${f.line ? chalk.gray(` (line ${f.line})`) : ''}`);
185
+ }
186
+
187
+ // ── Plan → apply → verify ladder, with one evidence-fed retry ──────────
188
+ const maxAttempts = options.maxAttempts || 2;
189
+ let retryEvidence = '';
190
+ let decision = null;
191
+ let finalPlan = null;
192
+ let finalVerified = null;
193
+ let snapshots = []; // files touched by the currently applied attempt
194
+ let ladderFailed = false; // verification failed with nothing resolved
195
+
196
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
197
+ if (retryEvidence) {
198
+ console.log(chalk.yellow(` Verification failed — retrying plan with failure evidence (attempt ${attempt}/${maxAttempts})`));
199
+ }
200
+
201
+ // Generate plan
202
+ const planSpinner = ora({ text: retryEvidence ? 'Regenerating fix plan...' : 'Generating fix plan...', color: 'cyan', indent: 6 }).start();
203
+ const planResult = await generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence);
204
+ planSpinner.stop();
205
+
206
+ if (!planResult.ok) {
207
+ const detail = describePlanFailure(planResult);
208
+ console.log(chalk.yellow(` ${detail.message}`));
209
+ logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), ...planResult });
210
+ skipped.push({ file: filePath, findings: fileFindings, reason: detail.reason });
211
+ finalPlan = null;
212
+ break;
213
+ }
214
+ const plan = planResult.plan;
215
+
216
+ // Validate (allows new safe files like .env.example)
217
+ const validation = validatePlan(root, plan);
218
+ if (!validation.ok) {
219
+ console.log(chalk.yellow(` Plan invalid: ${validation.reason}`));
220
+ logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), reason: 'validation-rejected', detail: validation.reason, plan });
221
+ skipped.push({ file: filePath, findings: fileFindings, reason: `plan-invalid: ${validation.reason}` });
222
+ finalPlan = null;
223
+ break;
224
+ }
225
+
226
+ // Show plan
227
+ printPlan(plan, root);
228
+
229
+ if (options.planOnly) {
230
+ console.log(chalk.gray(' (plan-only mode — not applying)'));
231
+ finalPlan = null;
232
+ break;
233
+ }
234
+
235
+ // Each distinct plan needs approval, unless an explicit auto mode is enabled.
236
+ if (decision === null) {
237
+ const risk = (plan.risk || 'medium').toLowerCase();
238
+ if (options.yolo || options.ci) {
239
+ decision = 'a';
240
+ console.log(chalk.gray(` (${options.ci ? 'ci' : 'yolo'}: auto-accepting)`));
241
+ } else if (options.autoLow && risk === 'low') {
242
+ decision = 'a';
243
+ console.log(chalk.gray(' (auto-low: low-risk, auto-accepting)'));
244
+ } else {
245
+ decision = await promptDecision(plan, root);
246
+ }
247
+ }
248
+
249
+ if (decision === 'q' || decision === 'quit') {
250
+ console.log(chalk.gray(' Stopping.'));
251
+ stopped = true;
252
+ finalPlan = null;
253
+ break;
254
+ }
255
+ if (!['a', 'accept', 'y', 'yes'].includes(decision)) {
256
+ skipped.push({ file: filePath, findings: fileFindings, reason: 'user-skipped' });
257
+ finalPlan = null;
258
+ break;
259
+ }
260
+
261
+ // Revalidate after approval and roll back a partially applied plan on error.
262
+ let applyErr = null;
263
+ try {
264
+ snapshots = applyPlan(root, plan);
265
+ } catch (err) {
266
+ applyErr = err.message;
267
+ }
268
+
269
+ if (applyErr) {
270
+ console.log(chalk.red(` Apply failed: ${applyErr}`));
271
+ skipped.push({ file: filePath, findings: fileFindings, reason: `apply-failed: ${applyErr}` });
272
+ finalPlan = null;
273
+ break;
274
+ }
275
+
276
+ // Verify — tiered ladder (build → tests → re-scan)
277
+ const verifySpinner = ora({ text: 'Verifying (build → tests → re-scan)...', color: 'cyan', indent: 6 }).start();
278
+ const verified = await verifyFile(root, filePath, fileFindings, options);
279
+ finalPlan = plan;
280
+ finalVerified = verified;
281
+
282
+ if (verified.allResolved) {
283
+ const ranTiers = (verified.tiers || []).map(t => t.tier);
284
+ verifySpinner.succeed(chalk.green(`Fix verified — ${fileFindings.length} finding(s) resolved (${[...ranTiers, 're-scan'].join(' → ')})`));
285
+ ladderFailed = false;
286
+ break;
287
+ }
288
+
289
+ if (verified.someResolved) {
290
+ verifySpinner.warn(chalk.yellow(`Partial: ${verified.resolvedCount}/${fileFindings.length} resolved`));
291
+ ladderFailed = false;
292
+ } else {
293
+ verifySpinner.warn(chalk.yellow(`Verification failed at tier "${verified.failedTier}"`));
294
+ ladderFailed = true;
295
+ }
296
+
297
+ // Evidence-fed retry
298
+ if (attempt < maxAttempts && verified.evidence) {
299
+ restoreSnapshots(snapshots);
300
+ snapshots = [];
301
+ finalPlan = null;
302
+ finalVerified = null;
303
+ ladderFailed = false;
304
+ decision = null; // a regenerated plan needs its own approval
305
+ retryEvidence = verified.evidence;
306
+ continue;
307
+ }
308
+ break;
309
+ }
310
+
311
+ // Revert failed fixes (nothing resolved) so the repo stays consistent —
312
+ // even when the retry plan itself failed to generate.
313
+ if (ladderFailed && snapshots.length > 0) {
314
+ restoreSnapshots(snapshots);
315
+ console.log(chalk.yellow(' Reverted failed fix — file(s) restored to pre-fix state.'));
316
+ logFailure(root, {
317
+ timestamp: new Date().toISOString(),
318
+ file: filePath,
319
+ findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })),
320
+ reason: 'verification-failed',
321
+ detail: finalVerified?.evidence || 'verification failed',
322
+ plan: finalPlan,
323
+ });
324
+ skipped.push({ file: filePath, findings: fileFindings, reason: `verification-failed: ${finalVerified?.failedTier || 'unknown'}` });
325
+ continue;
326
+ }
327
+
328
+ if (!finalPlan || !finalVerified) continue;
329
+
330
+ // Per-fix commit (if branch isolation in use)
331
+ const written = finalPlan.files.map(fc => path.resolve(root, fc.path));
332
+ let commitHash = null;
333
+ if (branchCreated) {
334
+ try {
335
+ execFileSync('git', ['add', '--', ...written], { cwd: root, stdio: 'pipe' });
336
+ const titles = fileFindings.slice(0, 3).map(f => f.title).join(', ');
337
+ const more = fileFindings.length > 3 ? ` (+${fileFindings.length - 3} more)` : '';
338
+ const msg = `fix(security): ${filePath} — ${titles}${more}`;
339
+ execFileSync('git', ['commit', '-m', msg], { cwd: root, stdio: 'pipe' });
340
+ commitHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf-8' }).trim();
341
+ } catch {
342
+ // commit failed — most likely nothing staged because edits were no-ops
343
+ }
344
+ }
345
+
346
+ // Log
347
+ logFix(root, {
348
+ timestamp: new Date().toISOString(),
349
+ file: filePath,
350
+ findings: fileFindings.map(f => ({ title: f.title, line: f.line, severity: f.severity, rule: f.rule })),
351
+ plan: finalPlan,
352
+ verified: finalVerified.allResolved,
353
+ verificationClass: finalVerified.verification,
354
+ verificationTiers: (finalVerified.tiers || []).map(t => ({ tier: t.tier, command: t.command, ok: t.ok })),
355
+ branch: branchCreated,
356
+ commitHash,
357
+ });
358
+
359
+ applied.push({ file: filePath, plan: finalPlan, verified: finalVerified });
360
+ }
361
+
362
+ // ── Final report ─────────────────────────────────────────────────────────
363
+ console.log();
364
+ console.log();
365
+ output.header('Summary');
366
+ console.log();
367
+ console.log(` ${chalk.green('Applied:')} ${applied.length} file(s)`);
368
+ console.log(` ${chalk.gray('Skipped:')} ${skipped.length} file(s)`);
369
+
370
+ if (applied.length > 0) {
371
+ console.log();
372
+ console.log(chalk.gray(' Applied:'));
373
+ for (const a of applied) {
374
+ const mark = a.verified.allResolved ? chalk.green('✓') : chalk.yellow('?');
375
+ console.log(` ${mark} ${a.file}`);
376
+ }
377
+ console.log();
378
+ console.log(chalk.gray(` Audit log: ${path.join(FIX_LOG_DIR, FIX_LOG_FILE)}`));
379
+ if (branchCreated) {
380
+ console.log(chalk.gray(` Branch: ${chalk.cyan(branchCreated)}`));
381
+ console.log(chalk.gray(` Switch back: git checkout ${initialBranch}`));
382
+ } else {
383
+ console.log(chalk.gray(' Review: git diff'));
384
+ console.log(chalk.gray(' Undo last: praxis undo'));
385
+ }
386
+ }
387
+
388
+ // ── PR autopilot ─────────────────────────────────────────────────────────
389
+ if (options.pr && applied.length > 0 && branchCreated) {
390
+ console.log();
391
+ await openPullRequest(root, branchCreated, applied);
392
+ } else if (options.pr && !branchCreated) {
393
+ console.log();
394
+ console.log(chalk.yellow(' --pr requires --branch. Skipping PR creation.'));
395
+ }
396
+
397
+ if (skipped.length > 0 && applied.length === 0) {
398
+ console.log();
399
+ console.log(chalk.gray(' Tip: try a different provider with --provider, or run with --plan-only'));
400
+ console.log(chalk.gray(' to inspect what would change.'));
401
+ }
402
+
403
+ console.log();
404
+ }
405
+
406
+ // =============================================================================
407
+ // PLAN GENERATION
408
+ // =============================================================================
409
+
410
+ // Generate a fix plan for a file. Returns either:
411
+ // { ok: true, plan } — plan generated successfully
412
+ // { ok: false, reason, raw, error } — failed; reason is one of:
413
+ // 'file-read-failed' | 'provider-error' | 'parse-error' |
414
+ // 'llm-declined' | 'empty-response'
415
+ // Caller decides what to do with the failure (log, persist, skip).
416
+ async function generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence = '') {
417
+ const abs = path.resolve(root, filePath);
418
+ let content;
419
+ try {
420
+ content = fs.readFileSync(abs, 'utf8');
421
+ } catch (err) {
422
+ return { ok: false, reason: 'file-read-failed', error: err.message };
423
+ }
424
+
425
+ const fileForPrompt = windowFileContent(content, fileFindings);
426
+
427
+ const findingsBlock = fileFindings.map((f, i) => `
428
+ ${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ''}
429
+ Rule: ${f.rule ?? 'N/A'}
430
+ Description: ${f.description ?? 'N/A'}${f.fix ? `\n Suggested fix: ${f.fix}` : ''}
431
+ `).join('');
432
+
433
+ const systemPrompt = 'You are a security engineer. Produce precise code edits as structured JSON only. Never include prose, markdown, or code fences. Output a single JSON object.';
434
+
435
+ const userPrompt = `Fix all of these security findings in a single file by producing one coordinated plan.
436
+
437
+ FILE: ${filePath}
438
+
439
+ FINDINGS (${fileFindings.length}):
440
+ ${findingsBlock}
441
+
442
+ CURRENT FILE CONTENT:
443
+ \`\`\`
444
+ ${fileForPrompt}
445
+ \`\`\`
446
+
447
+ OUTPUT this exact JSON shape:
448
+ {
449
+ "summary": "one short sentence describing what you'll do across all findings",
450
+ "files": [
451
+ {
452
+ "path": "${filePath}",
453
+ "edits": [
454
+ { "find": "EXACT verbatim substring", "replace": "new string", "reason": "addresses finding N" }
455
+ ]
456
+ }
457
+ ],
458
+ "risk": "low"
459
+ }
460
+
461
+ You MAY also include companion file changes (only these are allowed):
462
+ - .env.example — add placeholders for any secrets you moved to env vars
463
+ - .gitignore — add patterns for files that should not be committed
464
+
465
+ For companion files, use this shape (no "find" needed):
466
+ { "path": ".env.example", "create": true, "content": "FULL FILE CONTENT" }
467
+ or to append:
468
+ { "path": ".gitignore", "append": "PATTERN_TO_ADD\\n" }
469
+
470
+ RULES:
471
+ - Each "find" string must appear EXACTLY ONCE in the file. Include enough context (3+ lines) for uniqueness.
472
+ - "replace" must be the corrected code. Preserve indentation and surrounding style.
473
+ - Address each finding listed above with at least one edit (or explain in summary why a finding can't be mechanically fixed).
474
+ - GREP FOR SIBLING CALL SITES: search the file (and clearly-related helper files) for every other instance of the same dangerous pattern and fix all of them, not just the flagged line. A fix that leaves variant call sites behind fails verification.
475
+ - REGRESSION TEST: if this project has a test suite, include a test in your edits that fails before your fix and passes after it.
476
+ - Risk: "low" = mechanical, "medium" = behavior change, "high" = architectural. Use "high" sparingly.
477
+ - If you cannot produce a precise mechanical plan, return {"summary":"requires manual review","files":[],"risk":"high"}
478
+ - JSON only. No prose. No code fences.${retryEvidence ? `
479
+
480
+ PREVIOUS ATTEMPT FEEDBACK — your earlier plan was applied but automated verification failed:
481
+
482
+ ${retryEvidence}
483
+
484
+ Produce a corrected plan against the CURRENT file content below. Diagnose the root cause of the
485
+ verification failure (build break, failing test, or finding still present) and fix it.` : ''}`;
486
+
487
+ let response;
488
+ try {
489
+ response = await provider.complete(systemPrompt, userPrompt, {
490
+ maxTokens: 3000,
491
+ jsonMode: true,
492
+ });
493
+ } catch (err) {
494
+ return { ok: false, reason: 'provider-error', error: err.message };
495
+ }
496
+
497
+ if (!response || !response.trim()) {
498
+ return { ok: false, reason: 'empty-response', raw: response ?? '' };
499
+ }
500
+
501
+ const plan = parseJsonLoose(response);
502
+ if (!plan) {
503
+ return { ok: false, reason: 'parse-error', raw: response };
504
+ }
505
+ if (!Array.isArray(plan.files) || plan.files.length === 0) {
506
+ // The LLM returned valid JSON but explicitly declined to produce edits
507
+ // (typically with summary like "requires manual review"). This is a
508
+ // legitimate "I don't know" — not a bug.
509
+ return { ok: false, reason: 'llm-declined', raw: response, plan };
510
+ }
511
+
512
+ return { ok: true, plan };
513
+ }
514
+
515
+ function parseJsonLoose(response) {
516
+ if (!response) return null;
517
+ const cleaned = response.trim()
518
+ .replace(/^```(?:json)?\s*/i, '')
519
+ .replace(/```\s*$/i, '')
520
+ .trim();
521
+ try {
522
+ return JSON.parse(cleaned);
523
+ } catch {
524
+ const m = cleaned.match(/\{[\s\S]*\}/);
525
+ if (m) {
526
+ try { return JSON.parse(m[0]); } catch { return null; }
527
+ }
528
+ return null;
529
+ }
530
+ }
531
+
532
+ function windowFileContent(content, fileFindings) {
533
+ if (content.length <= 8000) return content;
534
+ if (!Array.isArray(fileFindings) || fileFindings.length === 0) {
535
+ return content.slice(0, 8000);
536
+ }
537
+
538
+ const lines = content.split('\n');
539
+ const RADIUS = 40;
540
+
541
+ const targetLines = [...new Set(fileFindings.map(f => f.line).filter(Boolean))].sort((a, b) => a - b);
542
+ if (targetLines.length === 0) return content.slice(0, 8000);
543
+
544
+ const ranges = [];
545
+ for (const line of targetLines) {
546
+ const start = Math.max(0, line - 1 - RADIUS);
547
+ const end = Math.min(lines.length - 1, line - 1 + RADIUS);
548
+ if (ranges.length === 0) {
549
+ ranges.push({ start, end });
550
+ } else {
551
+ const last = ranges[ranges.length - 1];
552
+ if (start <= last.end + 5) {
553
+ last.end = Math.max(last.end, end);
554
+ } else {
555
+ ranges.push({ start, end });
556
+ }
557
+ }
558
+ }
559
+
560
+ const result = [];
561
+ let lastEnd = 0;
562
+ for (const r of ranges) {
563
+ if (r.start > lastEnd) {
564
+ result.push(`// ... [lines ${lastEnd + 1}-${r.start} truncated] ...`);
565
+ }
566
+ result.push(lines.slice(r.start, r.end + 1).join('\n'));
567
+ lastEnd = r.end + 1;
568
+ }
569
+ if (lastEnd < lines.length) {
570
+ result.push(`// ... [lines ${lastEnd + 1}-${lines.length} truncated] ...`);
571
+ }
572
+ return result.join('\n');
573
+ }
574
+
575
+ // =============================================================================
576
+ // PLAN VALIDATION
577
+ // =============================================================================
578
+
579
+ // =============================================================================
580
+ // PRINTING
581
+ // =============================================================================
582
+
583
+ function printPlan(plan, _root) {
584
+ console.log();
585
+ console.log(chalk.bold(' Plan:'));
586
+ console.log(chalk.white(` ${plan.summary || '(no summary)'}`));
587
+ if (plan.risk) {
588
+ const riskColor = plan.risk === 'low' ? chalk.green : plan.risk === 'medium' ? chalk.yellow : chalk.red;
589
+ console.log(` Risk: ${riskColor(plan.risk)}`);
590
+ }
591
+ console.log();
592
+
593
+ for (const f of plan.files) {
594
+ if (f.create) {
595
+ console.log(chalk.bold(` ${chalk.green('+ ')}${f.path} ${chalk.gray('(new file)')}`));
596
+ printNewFilePreview(f.content);
597
+ continue;
598
+ }
599
+ if (f.append !== undefined) {
600
+ console.log(chalk.bold(` ${f.path} ${chalk.gray('(append)')}`));
601
+ for (const l of f.append.split('\n')) {
602
+ if (l) console.log(chalk.green(` + ${l}`));
603
+ }
604
+ continue;
605
+ }
606
+ console.log(chalk.bold(` ${f.path}`));
607
+ for (const e of f.edits) {
608
+ console.log(chalk.gray(` — ${e.reason || 'edit'}`));
609
+ printDiff(e._resolvedFind || e.find, e.replace);
610
+ }
611
+ }
612
+ console.log();
613
+ }
614
+
615
+ function printNewFilePreview(content) {
616
+ const lines = content.split('\n');
617
+ const max = 6;
618
+ const shown = lines.slice(0, max);
619
+ for (const l of shown) console.log(chalk.green(` + ${l}`));
620
+ if (lines.length > max) {
621
+ console.log(chalk.gray(` … +${lines.length - max} more line(s)`));
622
+ }
623
+ }
624
+
625
+ function printDiff(oldStr, newStr) {
626
+ const oldLines = oldStr.split('\n');
627
+ const newLines = newStr.split('\n');
628
+ for (const l of oldLines) console.log(chalk.red(` - ${l}`));
629
+ for (const l of newLines) console.log(chalk.green(` + ${l}`));
630
+ }
631
+
632
+ function severityLabel(sev) {
633
+ switch (sev) {
634
+ case 'critical': return chalk.red.bold('[CRITICAL]');
635
+ case 'high': return chalk.red('[HIGH]');
636
+ case 'medium': return chalk.yellow('[MEDIUM]');
637
+ case 'low': return chalk.blue('[LOW]');
638
+ default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
639
+ }
640
+ }
641
+
642
+ // =============================================================================
643
+ // APPLY
644
+ // =============================================================================
645
+
646
+ // =============================================================================
647
+ // VERIFY — tiered ladder
648
+ // =============================================================================
649
+ // Executable oracles only; no tier is decided by model judgment:
650
+ // Tier 1 build/lint — the project's own build (or lint) must still pass
651
+ // Tier 2 test suite — the project's test command must still pass
652
+ // Tier 3 re-scan — original findings must be gone from the fixed file
653
+ // Failing tier evidence is returned so the caller can feed it into a retry.
654
+
655
+ function isDockerAvailable() {
656
+ try {
657
+ execFileSync('docker', ['info'], { stdio: 'ignore' });
658
+ return true;
659
+ } catch {
660
+ return false;
661
+ }
662
+ }
663
+
664
+ function detectBuildCommand(root) {
665
+ try {
666
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
667
+ if (typeof pkg.scripts?.build === 'string') return { label: 'build', command: ['npm', 'run', 'build'] };
668
+ if (typeof pkg.scripts?.lint === 'string') return { label: 'lint', command: ['npm', 'run', 'lint'] };
669
+ } catch { /* no package.json */ }
670
+ if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'build', command: ['cargo', 'build'] };
671
+ if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'build', command: ['go', 'build', './...'] };
672
+ if (fs.existsSync(path.join(root, 'Makefile'))) return { label: 'build', command: ['make'] };
673
+ return null;
674
+ }
675
+
676
+ function detectTestCommand(root) {
677
+ try {
678
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
679
+ if (typeof pkg.scripts?.test === 'string') return { label: 'test', command: ['npm', 'test'] };
680
+ } catch { /* no package.json */ }
681
+ if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'test', command: ['cargo', 'test'] };
682
+ if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'test', command: ['go', 'test', './...'] };
683
+ if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'pytest.ini'))) return { label: 'test', command: ['pytest'] };
684
+ return null;
685
+ }
686
+
687
+ function runProjectCommand(root, command, timeoutMs = 300000) {
688
+ const [cmd0, ...args] = command;
689
+ try {
690
+ let stdout;
691
+ if (process.platform === 'win32' && /^npm$/.test(cmd0)) {
692
+ // npm is a .cmd shim on Windows — spawnSync of .cmd files returns EINVAL
693
+ // without a shell. Commands are fixed internal strings (no user input).
694
+ const line = `npm.cmd ${args.map(a => `"${a}"`).join(' ')}`;
695
+ stdout = execFileSync(line, {
696
+ cwd: root,
697
+ shell: true,
698
+ encoding: 'utf-8',
699
+ timeout: timeoutMs,
700
+ maxBuffer: 10 * 1024 * 1024,
701
+ stdio: ['ignore', 'pipe', 'pipe'],
702
+ });
703
+ } else {
704
+ stdout = execFileSync(cmd0, args, {
705
+ cwd: root,
706
+ encoding: 'utf-8',
707
+ timeout: timeoutMs,
708
+ maxBuffer: 10 * 1024 * 1024,
709
+ stdio: ['ignore', 'pipe', 'pipe'],
710
+ });
711
+ }
712
+ return { ok: true, exitCode: 0, output: String(stdout).slice(-800) };
713
+ } catch (err) {
714
+ const output = String((err.stdout || '') + '\n' + (err.stderr || '')).slice(-800);
715
+ const timedOut = err.killed || /ETIMEDOUT|timed out/i.test(err.message);
716
+ return { ok: false, exitCode: err.status ?? -1, output, timedOut };
717
+ }
718
+ }
719
+
720
+ async function rescanForFile(root, filePath, options) {
721
+ if (options.sandbox && isDockerAvailable()) {
722
+ const stdout = execFileSync('docker', [
723
+ 'run', '--rm',
724
+ '-v', `${root}:/workspace`,
725
+ '-v', `${praxisDir}:/opt/praxis:ro`,
726
+ '-w', '/workspace',
727
+ 'node:18-alpine',
728
+ 'node', '/opt/praxis/cli/bin/praxis.js', 'scan', '.', '--json'
729
+ ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
730
+
731
+ const jsonStart = stdout.search(/^\s*\{/m);
732
+ if (jsonStart === -1) throw new Error('Sandbox re-scan produced no JSON');
733
+ const report = JSON.parse(stdout.slice(jsonStart));
734
+ if (!Array.isArray(report.findings)) throw new Error('Sandbox re-scan produced no finding list');
735
+ return report;
736
+ }
737
+ return auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
738
+ }
739
+
740
+ export async function verifyFile(root, filePath, originalFindings, options = {}) {
741
+ const tiers = [];
742
+
743
+ try {
744
+ // Tier 0 — AST syntax verification
745
+ const ext = path.extname(filePath).toLowerCase();
746
+ if (['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py'].includes(ext)) {
747
+ try {
748
+ const fileContent = fs.readFileSync(path.resolve(root, filePath), 'utf8');
749
+ const parsed = ASTParser.parse(fileContent, filePath);
750
+ const hasValidAST = parsed && parsed.ast && Array.isArray(parsed.ast.body);
751
+ tiers.push({ tier: 'ast_syntax', ok: hasValidAST });
752
+ if (!hasValidAST) {
753
+ return {
754
+ allResolved: false, someResolved: false, resolvedCount: 0,
755
+ verification: 'failed', failedTier: 'ast_syntax',
756
+ evidence: `AST syntax verification failed on fixed file: ${filePath}`,
757
+ tiers,
758
+ };
759
+ }
760
+ } catch (err) {
761
+ return {
762
+ allResolved: false, someResolved: false, resolvedCount: 0,
763
+ verification: 'failed', failedTier: 'ast_syntax',
764
+ evidence: `AST syntax verification failed: ${err.message}`,
765
+ tiers,
766
+ };
767
+ }
768
+ }
769
+
770
+ // Tier 1 — build/lint
771
+ const build = detectBuildCommand(root);
772
+ if (build) {
773
+ const r = runProjectCommand(root, build.command);
774
+ tiers.push({ tier: 'build', command: build.command.join(' '), ok: r.ok, output: r.output });
775
+ if (!r.ok) {
776
+ return {
777
+ allResolved: false, someResolved: false, resolvedCount: 0,
778
+ verification: 'failed', failedTier: 'build',
779
+ evidence: `Build failed (${build.label}): ${r.output}`.slice(0, 1200),
780
+ tiers,
781
+ };
782
+ }
783
+ }
784
+
785
+ // Tier 2 — test suite
786
+ const test = detectTestCommand(root);
787
+ if (test) {
788
+ const r = runProjectCommand(root, test.command, 600000);
789
+ tiers.push({ tier: 'test', command: test.command.join(' '), ok: r.ok, output: r.output });
790
+ if (!r.ok) {
791
+ return {
792
+ allResolved: false, someResolved: false, resolvedCount: 0,
793
+ verification: 'failed', failedTier: 'test',
794
+ evidence: `Tests failed (${test.label}): ${r.output}`.slice(0, 1200),
795
+ tiers,
796
+ };
797
+ }
798
+ }
799
+
800
+ // Tier 3 — re-scan: original findings must be gone from the fixed file
801
+ const result = await rescanForFile(root, filePath, options);
802
+ const remaining = (result.findings ?? []).filter(f => {
803
+ const fPath = path.resolve(root, f.file);
804
+ const targetPath = path.resolve(root, filePath);
805
+ return fPath === targetPath;
806
+ });
807
+
808
+ let resolvedCount = 0;
809
+ const survivors = [];
810
+ for (const orig of originalFindings) {
811
+ const stillThere = remaining.some(f =>
812
+ f.rule === orig.rule &&
813
+ Math.abs((f.line ?? 0) - (orig.line ?? 0)) <= 2,
814
+ );
815
+ if (!stillThere) resolvedCount++;
816
+ else survivors.push(orig.rule);
817
+ }
818
+
819
+ const allResolved = resolvedCount === originalFindings.length;
820
+ return {
821
+ allResolved,
822
+ someResolved: resolvedCount > 0,
823
+ resolvedCount,
824
+ verification: allResolved ? 'passed' : (resolvedCount > 0 ? 'partial' : 'failed'),
825
+ failedTier: allResolved ? null : 'rescan',
826
+ evidence: allResolved ? '' : `Re-scan still reports: ${survivors.join(', ')}`,
827
+ tiers,
828
+ };
829
+ } catch (err) {
830
+ return {
831
+ allResolved: false, someResolved: false, resolvedCount: 0,
832
+ verification: 'failed', failedTier: 'verify-error',
833
+ evidence: err.message || 'verification crashed',
834
+ tiers,
835
+ };
836
+ }
837
+ }
838
+
839
+ // =============================================================================
840
+ // LOGGING
841
+ // =============================================================================
842
+
843
+ function logFix(root, entry) {
844
+ const dir = path.join(root, FIX_LOG_DIR);
845
+ const file = path.join(dir, FIX_LOG_FILE);
846
+ fs.mkdirSync(dir, { recursive: true });
847
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
848
+ }
849
+
850
+ // Append a structured record for every plan that didn't produce an apply.
851
+ // Useful for debugging regressions (the LLM said X, we rejected because Y).
852
+ function logFailure(root, entry) {
853
+ const dir = path.join(root, FIX_LOG_DIR);
854
+ const file = path.join(dir, 'failures.jsonl');
855
+ fs.mkdirSync(dir, { recursive: true });
856
+ // Truncate raw response to keep the log readable
857
+ const truncated = { ...entry };
858
+ if (typeof truncated.raw === 'string' && truncated.raw.length > 4000) {
859
+ truncated.raw = truncated.raw.slice(0, 4000) + '... [truncated]';
860
+ }
861
+ fs.appendFileSync(file, JSON.stringify(truncated) + '\n', 'utf8');
862
+ }
863
+
864
+ // Turn a structured plan failure into a user-facing one-liner.
865
+ function describePlanFailure(failure) {
866
+ switch (failure.reason) {
867
+ case 'file-read-failed':
868
+ return { message: `Could not read source file: ${failure.error}`, reason: 'file-read-failed' };
869
+ case 'provider-error':
870
+ return { message: `LLM provider error — ${failure.error}`, reason: 'provider-error' };
871
+ case 'empty-response':
872
+ return { message: 'LLM returned an empty response (try again, or switch provider).', reason: 'empty-response' };
873
+ case 'parse-error':
874
+ return { message: 'LLM returned unparseable JSON — saved to .praxis/failures.jsonl for inspection.', reason: 'parse-error' };
875
+ case 'llm-declined': {
876
+ const summary = failure.plan?.summary;
877
+ return { message: summary
878
+ ? `LLM declined to fix this file — ${summary}`
879
+ : 'LLM declined to fix this file (returned files=[] — needs manual review).',
880
+ reason: 'llm-declined',
881
+ };
882
+ }
883
+ default:
884
+ return { message: `Plan failed: ${failure.reason}`, reason: failure.reason };
885
+ }
886
+ }
887
+
888
+ // =============================================================================
889
+ // PR AUTOPILOT
890
+ // =============================================================================
891
+
892
+ async function openPullRequest(root, branch, applied) {
893
+ const ghAvailable = (() => {
894
+ try { execFileSync('gh', ['--version'], { stdio: 'pipe' }); return true; }
895
+ catch { return false; }
896
+ })();
897
+ if (!ghAvailable) {
898
+ console.log(chalk.yellow(' gh CLI not found. Install from https://cli.github.com to enable --pr.'));
899
+ return;
900
+ }
901
+
902
+ // Push branch
903
+ console.log(chalk.gray(' Pushing branch...'));
904
+ try {
905
+ execFileSync('git', ['push', '-u', 'origin', branch], { cwd: root, stdio: 'pipe' });
906
+ } catch (err) {
907
+ console.log(chalk.red(` Push failed: ${err.message}`));
908
+ return;
909
+ }
910
+
911
+ // Build PR body
912
+ const totalFindings = applied.reduce((n, a) => n + (a.plan.files?.[0]?.edits?.length ?? 0), 0);
913
+ const body = [
914
+ '## Praxis — Security Fixes',
915
+ '',
916
+ `Applied ${applied.length} file(s) of fixes (${totalFindings} edit(s)) generated and verified by the Praxis agent.`,
917
+ '',
918
+ '### Files changed',
919
+ ...applied.map(a => {
920
+ const mark = a.verified.allResolved ? '✓' : '⚠';
921
+ return `- ${mark} \`${a.file}\` — ${a.plan.summary || 'security fix'}`;
922
+ }),
923
+ '',
924
+ '### Notes',
925
+ '- Each fix was generated by an LLM and verified by re-scanning the file.',
926
+ '- Files marked ⚠ have residual findings; review carefully before merging.',
927
+ '- Full audit log: `.praxis/fixes.jsonl`',
928
+ '',
929
+ 'Generated by `praxis agent`.',
930
+ ].join('\n');
931
+
932
+ const title = `Security fixes: ${applied.length} file(s)`;
933
+
934
+ console.log(chalk.gray(' Opening PR...'));
935
+ let prUrl = null;
936
+ try {
937
+ prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
938
+ console.log(chalk.green(` PR opened: ${prUrl}`));
939
+ } catch (err) {
940
+ console.log(chalk.red(` PR creation failed: ${err.message}`));
941
+ return;
942
+ }
943
+
944
+ // If we're running inside CI on a PR, leave a comment on the originating PR
945
+ // pointing at our fix PR. Detect from common GitHub Actions env vars.
946
+ const originPr = detectOriginPrNumber();
947
+ if (originPr) {
948
+ const note = [
949
+ `### 🛡️ Praxis Agent — fix PR opened`,
950
+ ``,
951
+ `The Praxis agent found fixable security issues triggered by this PR and opened **${prUrl}** with proposed fixes.`,
952
+ ``,
953
+ `**Files changed:** ${applied.length}`,
954
+ `**Total edits:** ${totalFindings}`,
955
+ ``,
956
+ `Review the fix PR and merge if it looks good.`,
957
+ ].join('\n');
958
+ try {
959
+ execFileSync('gh', ['pr', 'comment', String(originPr), '--body', note], { cwd: root, stdio: 'pipe' });
960
+ console.log(chalk.green(` Commented on origin PR #${originPr}`));
961
+ } catch (err) {
962
+ console.log(chalk.yellow(` Could not comment on origin PR #${originPr}: ${err.message}`));
963
+ }
964
+ }
965
+ }
966
+
967
+ // Detect the PR number that triggered this CI run. Supports GitHub Actions'
968
+ // pull_request and pull_request_target events. Returns null when not in CI
969
+ // or when the event isn't a PR event.
970
+ function detectOriginPrNumber() {
971
+ // Explicit override (handy for testing or non-GHA CI providers)
972
+ if (process.env.PRAXIS_ORIGIN_PR) return process.env.PRAXIS_ORIGIN_PR;
973
+
974
+ // GitHub Actions: GITHUB_REF looks like "refs/pull/<n>/merge" or "refs/pull/<n>/head"
975
+ const ref = process.env.GITHUB_REF || '';
976
+ const m = ref.match(/^refs\/pull\/(\d+)\//);
977
+ if (m) return m[1];
978
+
979
+ // GitHub Actions PR event payload also exposes the number via GITHUB_EVENT_PATH
980
+ const eventPath = process.env.GITHUB_EVENT_PATH;
981
+ if (eventPath && fs.existsSync(eventPath)) {
982
+ try {
983
+ const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
984
+ if (payload?.pull_request?.number) return String(payload.pull_request.number);
985
+ if (payload?.number) return String(payload.number);
986
+ } catch { /* malformed event payload */ }
987
+ }
988
+
989
+ return null;
990
+ }
991
+
992
+ // =============================================================================
993
+ // GIT
994
+ // =============================================================================
995
+
996
+ function checkGitState(root) {
997
+ try {
998
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'pipe' });
999
+ } catch {
1000
+ return 'not-a-repo';
1001
+ }
1002
+ try {
1003
+ const out = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
1004
+ const meaningful = out.split('\n').filter(line => {
1005
+ const path = line.slice(3).trim();
1006
+ if (!path) return false;
1007
+ if (path.startsWith('.praxis/')) return false;
1008
+ if (path === 'praxis-report.html') return false;
1009
+ return true;
1010
+ });
1011
+ return meaningful.length === 0 ? 'clean' : 'dirty';
1012
+ } catch {
1013
+ return 'clean';
1014
+ }
1015
+ }
1016
+
1017
+ function getCurrentBranch(root) {
1018
+ try {
1019
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ // =============================================================================
1026
+ // PROMPTS
1027
+ // =============================================================================
1028
+
1029
+ function prompt(question) {
1030
+ return new Promise(resolve => {
1031
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1032
+ rl.question(question, answer => { rl.close(); resolve(answer); });
1033
+ });
1034
+ }
1035
+
1036
+ async function confirm(question) {
1037
+ const a = (await prompt(`${question} [y/N] `)).trim().toLowerCase();
1038
+ return a === 'y' || a === 'yes';
1039
+ }
1040
+
1041
+ // Plan decision prompt with [e]dit support: opens the plan in $EDITOR for the
1042
+ // user to tweak, then re-validates. Loops until accept/skip/quit.
1043
+ async function promptDecision(plan, root) {
1044
+ while (true) {
1045
+ const raw = (await prompt(chalk.cyan(' [a]ccept [s]kip [e]dit [q]uit > '))).trim().toLowerCase();
1046
+ if (['a', 'accept', 'y', 'yes'].includes(raw)) return 'a';
1047
+ if (['s', 'skip', 'n', 'no'].includes(raw)) return 's';
1048
+ if (['q', 'quit'].includes(raw)) return 'q';
1049
+ if (['e', 'edit'].includes(raw)) {
1050
+ const edited = await editPlanInEditor(plan, root);
1051
+ if (!edited) {
1052
+ console.log(chalk.yellow(' Edit cancelled — keeping original plan.'));
1053
+ } else {
1054
+ const validation = validatePlan(root, edited);
1055
+ if (!validation.ok) {
1056
+ console.log(chalk.red(` Edited plan invalid: ${validation.reason}`));
1057
+ console.log(chalk.gray(' Returning to prompt — try editing again, or skip.'));
1058
+ continue;
1059
+ }
1060
+ // Replace the plan only after validation; an invalid edit cannot be accepted.
1061
+ plan.summary = edited.summary;
1062
+ plan.files = edited.files;
1063
+ plan.risk = edited.risk;
1064
+ printPlan(plan, root);
1065
+ }
1066
+ // Loop back and re-prompt
1067
+ continue;
1068
+ }
1069
+ console.log(chalk.gray(' Unknown choice. Type a, s, e, or q.'));
1070
+ }
1071
+ }
1072
+
1073
+ async function editPlanInEditor(plan, root) {
1074
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
1075
+ const tmpFile = path.join(root, '.praxis', `plan-edit-${Date.now()}.json`);
1076
+ fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
1077
+ // Strip _resolvedFind annotations before showing — they're internal
1078
+ const exportable = JSON.parse(JSON.stringify(plan, (k, v) => k === '_resolvedFind' ? undefined : v));
1079
+ fs.writeFileSync(tmpFile, JSON.stringify(exportable, null, 2), 'utf8');
1080
+
1081
+ try {
1082
+ execFileSync(editor, [tmpFile], { stdio: 'inherit' });
1083
+ const updated = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
1084
+ fs.unlinkSync(tmpFile);
1085
+ return updated;
1086
+ } catch (err) {
1087
+ try { fs.unlinkSync(tmpFile); } catch { /* best effort */ }
1088
+ console.log(chalk.red(` Editor failed: ${err.message}`));
1089
+ return null;
1090
+ }
1091
+ }