pisces-cli 0.1.0__py3-none-any.whl

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.
pisces/fix_cmd.py ADDED
@@ -0,0 +1,479 @@
1
+ """
2
+ pisces.fix_cmd
3
+ ~~~~~~~~~~~~~~
4
+ Implements `pisces fix <file:line>` — the full self-healing repair loop.
5
+ State machine:
6
+ DETECTED → PROPOSED → JUDGED → AWAITING_APPROVAL
7
+ → APPROVED → CHECKPOINTED → APPLIED → VALIDATING
8
+ → SUCCEEDED (terminal ✓)
9
+ → FAILED → HEALING → PROPOSED (retry loop)
10
+ → ROLLED_BACK (terminal, all attempts exhausted)
11
+ → REJECTED (terminal, human said No)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import subprocess
18
+ import uuid
19
+ from pathlib import Path
20
+
21
+ from pisces.config_manager import load_config
22
+ from pisces.context_manager import ContextManager
23
+ from pisces.explain_cmd import (
24
+ _CONTEXT_LINES,
25
+ _get_file_context,
26
+ _get_source_snippet,
27
+ _parse_ref,
28
+ )
29
+ from pisces.judge import judge_patch
30
+ from pisces.llm_client import call_llm
31
+ from pisces.models import Finding, JudgeResult, LLMRequest, RepairState, Severity
32
+ from pisces.patch_engine import (
33
+ PATCH_END,
34
+ PATCH_START,
35
+ apply_patch,
36
+ checkpoint_file,
37
+ extract_patch_code,
38
+ locate_function_lines,
39
+ rollback,
40
+ save_patch_record,
41
+ validate_patch_syntax,
42
+ )
43
+ from pisces.scan_cmd import load_finding_by_ref
44
+ from pisces.tui import (
45
+ console,
46
+ print_diff,
47
+ print_error,
48
+ print_hitl_approval,
49
+ print_section,
50
+ print_step_fail,
51
+ print_step_ok,
52
+ print_step_running,
53
+ print_success,
54
+ print_warning,
55
+ spinner,
56
+ )
57
+
58
+ # ─────────────────────────────────────────────────────────────
59
+ # Patch generation system prompt
60
+ # ─────────────────────────────────────────────────────────────
61
+ _FIX_SYSTEM = f"""\
62
+ You are an expert Python security engineer. Your task is to produce a
63
+ minimal, correct security patch for the vulnerability described.
64
+ Rules you MUST follow:
65
+ 1. Fix ONLY the vulnerability — do not refactor or reformat other code.
66
+ 2. Preserve the exact function signature and return type.
67
+ 3. The fix must be correct Python 3 and syntactically valid.
68
+ 4. Include a one-line comment explaining each security-relevant change.
69
+ 5. Output the replacement code BETWEEN these exact markers and nowhere else:
70
+ {PATCH_START}
71
+ <replacement code here>
72
+ {PATCH_END}
73
+ Do NOT include anything outside the markers.
74
+ Do NOT include the markers themselves as part of the code.
75
+ Output ONLY the patched code block, nothing else.
76
+ """
77
+
78
+
79
+ # ─────────────────────────────────────────────────────────────
80
+ # Entry point
81
+ # ─────────────────────────────────────────────────────────────
82
+ def run_fix(
83
+ ref: str,
84
+ project_root: Path,
85
+ dry_run: bool = False, # Generate and display a patch without modifying the source file.
86
+ skip_judge: bool = False, # Skips the LLM based patch evaluation
87
+ max_attempts: int
88
+ | None = None, # Overrides the configured retry limit for self-healing attempts.
89
+ auto_approve: bool = False, # Skips human-in-the-loop approval. This is not recommended for production use.
90
+ ) -> int:
91
+ """
92
+ Entry point for `pisces fix <ref>`.
93
+ Returns 0 on success (SUCCEEDED), 1 on failure/rejection.
94
+ """
95
+ print_section("pisces fix — Self-Healing Repair")
96
+ # ── Parse reference ─────────────────────────────────────
97
+ file_path, line_no = _parse_ref(ref, project_root)
98
+ if file_path is None:
99
+ print_error(f"Invalid reference: '{ref}' (expected: path/to/file.py:42)")
100
+ return 1
101
+ rel_path = str(file_path.relative_to(project_root))
102
+ lookup = f"{rel_path}:{line_no}"
103
+ # ── Load finding ─────────────────────────────────────────
104
+ finding = load_finding_by_ref(project_root, lookup)
105
+ if finding is None:
106
+ print_error(f"No finding cached at '{lookup}'.\n Run 'pisces scan .' first.")
107
+ return 1
108
+ # ── Load config ──────────────────────────────────────────
109
+ try:
110
+ cfg = load_config(project_root)
111
+ except RuntimeError as e:
112
+ print_error(str(e))
113
+ return 1
114
+ provider = cfg.llm.provider
115
+ model = cfg.llm.model
116
+ max_attempts = max_attempts if max_attempts is not None else cfg.repair.max_attempts
117
+ require_hitl = cfg.repair.require_approval and not auto_approve
118
+ test_command = cfg.validation.test_command
119
+ repair_id = str(uuid.uuid4())[:8]
120
+ checkpoint_path: Path | None = None
121
+ state = RepairState.DETECTED
122
+ console.print(
123
+ f" [dim]Finding:[/] [cyan]{lookup}[/] [dim]Severity:[/] {_severity_badge(finding.severity)}"
124
+ )
125
+ console.print(f" [dim]Provider:[/] [magenta]{provider}[/] / [cyan]{model}[/]")
126
+ console.print(
127
+ f" [dim]Max attempts:[/] {max_attempts} | [dim]HITL:[/] {'yes' if require_hitl else 'no (--auto-approve)'}"
128
+ )
129
+ console.print()
130
+ # ── Locate function boundaries ───────────────────────────
131
+ fn_start, fn_end = locate_function_lines(file_path, line_no)
132
+ original_block = _read_lines(file_path, fn_start, fn_end)
133
+ source_snippet = _get_source_snippet(file_path, line_no, context=_CONTEXT_LINES)
134
+ file_ctx = _get_file_context(project_root, rel_path)
135
+ # The repair loop
136
+ state = RepairState.DETECTED
137
+ prior_fail: str | None = None
138
+ for attempt in range(1, max_attempts + 1):
139
+ console.rule(
140
+ f"[bold]Attempt {attempt} / {max_attempts}[/]",
141
+ style="dim blue",
142
+ )
143
+ # ── Step 1: Generate patch ────────────────────────────
144
+ state = RepairState.PROPOSED
145
+ print_step_running(f"[{attempt}] Generating patch with {provider}/{model} ...")
146
+ patch_code = _generate_patch(
147
+ finding=finding,
148
+ source_snippet=source_snippet,
149
+ file_ctx_str=_fmt_file_ctx(file_ctx),
150
+ fn_start=fn_start,
151
+ fn_end=fn_end,
152
+ original_block=original_block,
153
+ provider=provider,
154
+ model=model,
155
+ prior_failure=prior_fail,
156
+ )
157
+ if patch_code is None:
158
+ print_step_fail("LLM did not produce a valid patch block.")
159
+ state = RepairState.FAILED
160
+ prior_fail = "LLM failed to output a patch in the expected format."
161
+ continue
162
+ # Validate patch syntax before judging
163
+ valid, err = validate_patch_syntax(patch_code)
164
+ if not valid:
165
+ print_step_fail(f"Patch has syntax error: {err}")
166
+ state = RepairState.FAILED
167
+ prior_fail = f"Patch syntax error: {err}"
168
+ continue
169
+ print_step_ok(f"Patch generated ({patch_code.count(chr(10)) + 1} lines)")
170
+ # ── Step 2: Show diff ─────────────────────────────────
171
+ diff_text = "".join(
172
+ difflib.unified_diff(
173
+ original_block.splitlines(keepends=True),
174
+ patch_code.splitlines(keepends=True),
175
+ fromfile=f"a/{rel_path}",
176
+ tofile=f"b/{rel_path}",
177
+ )
178
+ )
179
+
180
+ print_diff(
181
+ diff_text=diff_text or "No differences detected.\n",
182
+ filename=rel_path,
183
+ )
184
+ if dry_run:
185
+ console.print(
186
+ "[bold yellow]DRY RUN:[/] Proposed patch displayed;"
187
+ "the original file has NOT been modified."
188
+ )
189
+ return 0
190
+ # ── Step 3: Judge ─────────────────────────────────────
191
+
192
+ state = RepairState.JUDGED
193
+
194
+ if skip_judge:
195
+ print_warning("LLM judge skipped by --no-judge.")
196
+ judge = _fallback_judge()
197
+ else:
198
+ print_step_running("Judge reviewing the patch ...")
199
+ try:
200
+ with spinner("Running judge LLM..."):
201
+ judge = judge_patch(
202
+ finding=finding,
203
+ original_code=original_block,
204
+ patched_code=patch_code,
205
+ provider=provider,
206
+ model=model,
207
+ prior_failure=prior_fail,
208
+ )
209
+ except (RuntimeError, OSError, ValueError, TypeError) as e:
210
+ print_warning(f"Judge failed: {e} — proceeding anyway.")
211
+ judge = _fallback_judge()
212
+
213
+ _print_judge_result(judge)
214
+
215
+ if not judge.approved:
216
+ print_warning(f"Judge REJECTED this patch: {judge.reasoning[:200]}")
217
+ state = RepairState.FAILED
218
+ prior_fail = f"Judge rejected: {judge.reasoning}"
219
+ if attempt < max_attempts:
220
+ console.print(" [dim]Retrying with judge feedback...[/]\n")
221
+ continue
222
+ else:
223
+ _save_and_exit(
224
+ project_root, repair_id, finding, patch_code, judge, None, state.value
225
+ )
226
+ return 1
227
+ # ── Step 4: HITL Approval ─────────────────────────────
228
+ state = RepairState.AWAITING_APPROVAL
229
+ if require_hitl:
230
+ approved = print_hitl_approval(
231
+ finding=finding.to_dict(),
232
+ patch_summary=f"{patch_code.count(chr(10)) + 1} lines, "
233
+ f"quality={judge.patch_quality}, risk={judge.regression_risk}",
234
+ judge_result=judge.to_dict(),
235
+ )
236
+ else:
237
+ console.print(" [dim]--auto-approve active — skipping human review.[/]")
238
+ approved = True
239
+ if not approved:
240
+ state = RepairState.REJECTED
241
+ console.print()
242
+ console.print("[high]✗ Patch rejected by user.[/]")
243
+ _save_and_exit(project_root, repair_id, finding, patch_code, judge, False, state.value)
244
+ return 1
245
+ state = RepairState.APPROVED
246
+ # ── Step 5: Checkpoint ────────────────────────────────
247
+ state = RepairState.CHECKPOINTED
248
+ try:
249
+ checkpoint_path = checkpoint_file(file_path, project_root, repair_id)
250
+ except OSError as e:
251
+ print_step_fail(f"Checkpoint failed: {e}")
252
+ return 1
253
+ # ── Step 6: Apply patch ───────────────────────────────
254
+ state = RepairState.APPLIED
255
+ try:
256
+ apply_patch(file_path, patch_code, fn_start, fn_end)
257
+ except (OSError, ValueError) as e:
258
+ print_step_fail(f"Patch application failed: {e}")
259
+ if checkpoint_path:
260
+ rollback(file_path, checkpoint_path)
261
+ state = RepairState.ROLLED_BACK
262
+ _save_and_exit(project_root, repair_id, finding, patch_code, judge, True, state.value)
263
+ return 1
264
+ # ── Step 7: Validate (run tests) ──────────────────────
265
+ state = RepairState.VALIDATING
266
+ console.print()
267
+ print_step_running(f"Running validation: [cyan]{test_command}[/] ...")
268
+ test_ok, test_output = _run_tests(project_root, test_command)
269
+ if test_ok:
270
+ state = RepairState.SUCCEEDED
271
+ console.print()
272
+ print_success(
273
+ f"✓ Repair SUCCEEDED (attempt {attempt}/{max_attempts})\n\n"
274
+ f" The vulnerability in [cyan]{rel_path}:{line_no}[/] has been fixed.\n"
275
+ f" Run [cyan]pisces scan .[/] to confirm no remaining issues."
276
+ )
277
+ _save_and_exit(project_root, repair_id, finding, patch_code, judge, True, state.value)
278
+ # Invalidate context for the patched file
279
+ try:
280
+ cm = ContextManager(project_root)
281
+ cm.update_single_file(file_path)
282
+ except (OSError, RuntimeError, ValueError):
283
+ print_warning("Could not update project context after the repair.")
284
+ return 0
285
+ # ── Tests failed → healing loop ───────────────────────
286
+ state = RepairState.FAILED
287
+ print_step_fail("Test suite failed after patching.")
288
+ console.print(f" [dim]{test_output[:400]}[/]\n")
289
+ if checkpoint_path:
290
+ rollback(file_path, checkpoint_path)
291
+ state = RepairState.HEALING
292
+ prior_fail = f"Patch was applied but tests failed:\n{test_output[:600]}"
293
+ if attempt < max_attempts:
294
+ console.print(" [dim]Healing — will regenerate patch with failure context.[/]\n")
295
+ continue
296
+ # ── All attempts exhausted ────────────────────────────────
297
+ state = RepairState.ROLLED_BACK
298
+ print_step_fail(f"All {max_attempts} repair attempt(s) exhausted — original file restored.")
299
+ _save_and_exit(project_root, repair_id, finding, "", None, None, state.value)
300
+ return 1
301
+
302
+
303
+ # ─────────────────────────────────────────────────────────────
304
+ # Patch generation
305
+ # ─────────────────────────────────────────────────────────────
306
+ def _generate_patch(
307
+ finding: Finding,
308
+ source_snippet: str,
309
+ file_ctx_str: str,
310
+ fn_start: int,
311
+ fn_end: int,
312
+ original_block: str,
313
+ provider: str,
314
+ model: str,
315
+ prior_failure: str | None,
316
+ ) -> str | None:
317
+ prior_section = ""
318
+ if prior_failure:
319
+ prior_section = f"""
320
+ ## ⚠ Prior Attempt Failed
321
+ Your previous patch was tried and failed for this reason:
322
+ {prior_failure[:800]}
323
+
324
+ Study this carefully and produce an improved patch.
325
+ """
326
+ user_prompt = f"""\
327
+ ## Security Finding
328
+ - File: `{finding.file}`
329
+ - Line: {finding.line}
330
+ - Rule: `{finding.rule}`
331
+ - Severity: {finding.severity.value}
332
+ - Issue: {finding.description}
333
+ - Code: `{finding.code}`
334
+ - Impact: {finding.impact}
335
+ - Fix hint: {finding.remediation}
336
+ ---
337
+ ## Function to Patch (lines {fn_start}–{fn_end})
338
+ ```python
339
+ {original_block}
340
+ Surrounding Context (for reference only — do NOT include in patch)
341
+ python
342
+ {source_snippet}
343
+ File-Level Context
344
+ {file_ctx_str} {prior_section}
345
+ Produce the fixed replacement for the function shown above. Wrap it EXACTLY in {PATCH_START} and {PATCH_END} markers. """
346
+
347
+ request = LLMRequest(
348
+ system_prompt=_FIX_SYSTEM,
349
+ user_prompt=user_prompt,
350
+ temperature=0.15,
351
+ max_tokens=2048,
352
+ )
353
+ try:
354
+ with spinner("Generating patch..."):
355
+ response = call_llm(request, provider=provider, model=model)
356
+ except (OSError, RuntimeError) as e:
357
+ print_step_fail(str(e))
358
+ return None
359
+ return extract_patch_code(response.content)
360
+
361
+
362
+ # ─────────────────────────────────────────────────────────────
363
+ # Test runner
364
+ # ─────────────────────────────────────────────────────────────
365
+ def _run_tests(project_root: Path, test_command: str) -> tuple[bool, str]:
366
+ """
367
+ Run test_command in project_root.
368
+
369
+ Returns (success, combined_output).
370
+ """
371
+ try:
372
+ result = subprocess.run(
373
+ test_command.split(),
374
+ cwd=project_root,
375
+ capture_output=True,
376
+ text=True,
377
+ timeout=120,
378
+ shell=False, # deliberately NOT shell=True
379
+ check=False,
380
+ )
381
+ output = (result.stdout + "\n" + result.stderr).strip()
382
+ return result.returncode == 0, output
383
+ except FileNotFoundError:
384
+ return True, f"Test command '{test_command}' not found — skipping validation."
385
+ except subprocess.TimeoutExpired:
386
+ return False, "Test command timed out after 120 seconds."
387
+ except (OSError, ValueError, subprocess.SubprocessError) as e:
388
+ return True, f"Test runner error (non-fatal): {e}"
389
+
390
+
391
+ # ─────────────────────────────────────────────────────────────
392
+ # Display helpers
393
+ # ─────────────────────────────────────────────────────────────
394
+ def _print_judge_result(judge: JudgeResult) -> None:
395
+ decision_color = "success" if judge.approved else "high"
396
+
397
+ console.print(
398
+ f" Judge: [{decision_color}]{judge.decision}[/] "
399
+ f"security_ok={judge.security_ok} "
400
+ f"risk=[bold]{judge.regression_risk}[/] "
401
+ f"quality={judge.patch_quality}"
402
+ )
403
+ console.print(f" [dim]{judge.reasoning[:180]}[/]")
404
+ console.print()
405
+
406
+
407
+ def _severity_badge(sev: Severity) -> str:
408
+ colors = {
409
+ Severity.CRITICAL: "bold red",
410
+ Severity.HIGH: "red",
411
+ Severity.MEDIUM: "yellow",
412
+ Severity.LOW: "green",
413
+ Severity.INFO: "dim",
414
+ }
415
+
416
+ color = colors.get(sev, "white")
417
+ return f"[{color}]{sev.value}[/]"
418
+
419
+
420
+ def _fmt_file_ctx(file_ctx) -> str:
421
+ if file_ctx is None:
422
+ return "Not available."
423
+
424
+ lines = []
425
+
426
+ if file_ctx.imports:
427
+ lines.append("Imports: " + ", ".join(i.module for i in file_ctx.imports[:12]))
428
+
429
+ if file_ctx.functions:
430
+ lines.append("Functions: " + ", ".join(f.name for f in file_ctx.functions[:8]))
431
+
432
+ return "\\n".join(lines) or "Not available."
433
+
434
+
435
+ def _read_lines(file_path: Path, start: int, end: int) -> str:
436
+ try:
437
+ lines = file_path.read_text(encoding="utf-8").splitlines()
438
+ return "\\n".join(lines[start - 1 : end])
439
+ except OSError:
440
+ return ""
441
+
442
+
443
+ def _fallback_judge() -> JudgeResult:
444
+ """When judge LLM itself fails, return a permissive default."""
445
+ from pisces.models import JudgeResult
446
+
447
+ return JudgeResult(
448
+ decision="APPROVED",
449
+ security_ok=True,
450
+ functionality_ok=True,
451
+ scope_ok=True,
452
+ regression_risk="MEDIUM",
453
+ patch_quality="ACCEPTABLE",
454
+ completeness="COMPLETE",
455
+ reasoning="Judge LLM unavailable — proceeding with HITL review.",
456
+ )
457
+
458
+
459
+ def _save_and_exit(
460
+ project_root,
461
+ repair_id,
462
+ finding,
463
+ patch_code,
464
+ judge,
465
+ human_approved,
466
+ state,
467
+ ):
468
+ try:
469
+ save_patch_record(
470
+ project_root=project_root,
471
+ repair_id=repair_id,
472
+ finding_dict=finding.to_dict(),
473
+ patch_code=patch_code,
474
+ judge_result_dict=judge.to_dict() if judge else None,
475
+ human_approved=human_approved,
476
+ state=state,
477
+ )
478
+ except (OSError, TypeError, ValueError):
479
+ print_warning("Could not save the repair history record.")