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/patch_engine.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ pisces.patch_engine
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Handles patch parsing, application, checkpointing, and rollback.
5
+
6
+ The patch format pisces uses is a "block replacement":
7
+ The LLM outputs a function (or set of lines) to replace.
8
+ We locate the old function boundaries via AST, then do a line-range
9
+ swap. This is far more reliable than applying unified diffs.
10
+
11
+ Atomicity guarantee:
12
+ 1. Checkpoint — copy file to .pisces/history/<id>/original.py
13
+ 2. Write — write new content to file.py.tmp
14
+ 3. Rename — file.py.tmp → file.py (atomic on most OSes)
15
+ 4. Rollback — if anything fails, copy original.py → file.py
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import shutil
22
+ from pathlib import Path
23
+
24
+ from pisces.tui import print_step_fail, print_step_ok, print_step_running, print_warning
25
+
26
+ # ─────────────────────────────────────────────────────────────
27
+ # LLM patch output format
28
+ # ─────────────────────────────────────────────────────────────
29
+
30
+ # The fix prompt asks the LLM to wrap patched code in these markers:
31
+ PATCH_START = "<<<PISCES_PATCH_START>>>"
32
+ PATCH_END = "<<<PISCES_PATCH_END>>>"
33
+
34
+
35
+ # ─────────────────────────────────────────────────────────────
36
+ # Patch extraction from LLM response
37
+ # ─────────────────────────────────────────────────────────────
38
+
39
+
40
+ def extract_patch_code(llm_response: str) -> str | None:
41
+ """
42
+ Extract the patched code block from the LLM response.
43
+
44
+ Looks for text between PATCH_START and PATCH_END markers.
45
+ Falls back to extracting the first ```python ... ``` code block.
46
+
47
+ Returns the code string, or None if no patch found.
48
+ """
49
+ # Primary: marker-based extraction
50
+ if PATCH_START in llm_response and PATCH_END in llm_response:
51
+ start = llm_response.index(PATCH_START) + len(PATCH_START)
52
+ end = llm_response.index(PATCH_END)
53
+ code = llm_response[start:end].strip()
54
+ # Strip any accidental ```python fences inside the markers
55
+ code = _strip_fences(code)
56
+ return code if code else None
57
+
58
+ # Fallback: first ```python block
59
+ import re
60
+
61
+ pattern = re.compile(r"```python\s*\n(.*?)```", re.DOTALL)
62
+ match = pattern.search(llm_response)
63
+ if match:
64
+ return match.group(1).strip()
65
+
66
+ return None
67
+
68
+
69
+ def _strip_fences(code: str) -> str:
70
+ """Remove leading/trailing ``` fences from a code block."""
71
+ lines = code.strip().splitlines()
72
+ if lines and lines[0].strip().startswith("```"):
73
+ lines = lines[1:]
74
+ if lines and lines[-1].strip() == "```":
75
+ lines = lines[:-1]
76
+ return "\n".join(lines)
77
+
78
+
79
+ # ─────────────────────────────────────────────────────────────
80
+ # Patch validation — is the replacement valid Python?
81
+ # ─────────────────────────────────────────────────────────────
82
+
83
+
84
+ def validate_patch_syntax(patch_code: str) -> tuple[bool, str]:
85
+ """
86
+ Return (ok, error_message).
87
+ Tries to parse the patch_code as Python and reports any SyntaxError.
88
+ """
89
+ try:
90
+ ast.parse(patch_code)
91
+ return True, ""
92
+ except SyntaxError as e:
93
+ return False, f"SyntaxError at line {e.lineno}: {e.msg}"
94
+
95
+
96
+ # ─────────────────────────────────────────────────────────────
97
+ # Checkpoint — save original file before patching
98
+ # ─────────────────────────────────────────────────────────────
99
+
100
+
101
+ def checkpoint_file(
102
+ file_path: Path,
103
+ project_root: Path,
104
+ repair_id: str,
105
+ ) -> Path:
106
+ """
107
+ Copy *file_path* to .pisces/history/<repair_id>/original.py.
108
+
109
+ Returns the path of the checkpoint copy.
110
+ Raises OSError if the copy fails.
111
+ """
112
+ history_dir = project_root / ".pisces" / "history" / repair_id
113
+ history_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ checkpoint = history_dir / "original.py"
116
+ shutil.copy2(file_path, checkpoint)
117
+ print_step_ok(f"Checkpoint saved: [dim]{checkpoint.relative_to(project_root)}[/]")
118
+ return checkpoint
119
+
120
+
121
+ def save_patch_record(
122
+ project_root: Path,
123
+ repair_id: str,
124
+ finding_dict: dict,
125
+ patch_code: str,
126
+ judge_result_dict: dict | None,
127
+ human_approved: bool | None,
128
+ state: str,
129
+ ) -> None:
130
+ """Write metadata.json and patch.py to the repair history directory."""
131
+ import json
132
+ from datetime import datetime, timezone
133
+
134
+ history_dir = project_root / ".pisces" / "history" / repair_id
135
+ history_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ # patch.py — the actual replacement code
138
+ (history_dir / "patch.py").write_text(patch_code, encoding="utf-8")
139
+
140
+ # metadata.json
141
+ meta = {
142
+ "repair_id": repair_id,
143
+ "state": state,
144
+ "finding": finding_dict,
145
+ "judge_result": judge_result_dict,
146
+ "human_approved": human_approved,
147
+ "created_at": datetime.now(timezone.utc).isoformat(),
148
+ }
149
+ (history_dir / "metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
150
+
151
+
152
+ # ─────────────────────────────────────────────────────────────
153
+ # Patch application — line-range swap
154
+ # ─────────────────────────────────────────────────────────────
155
+
156
+
157
+ def locate_function_lines(
158
+ file_path: Path,
159
+ finding_line: int,
160
+ ) -> tuple[int, int]:
161
+ """
162
+ Return (line_start, line_end) of the function that *contains* finding_line.
163
+
164
+ If no enclosing function is found, returns (finding_line, finding_line)
165
+ — we still patch just that one line.
166
+
167
+ Uses AST for accuracy (accounts for decorators, nested functions, etc.)
168
+ """
169
+ try:
170
+ source = file_path.read_text(encoding="utf-8", errors="replace")
171
+ tree = ast.parse(source)
172
+ except (OSError, SyntaxError):
173
+ return finding_line, finding_line
174
+
175
+ best: tuple[int, int] | None = None
176
+ best_size = float("inf")
177
+
178
+ for node in ast.walk(tree):
179
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
180
+ continue
181
+ start = node.lineno
182
+ end = node.end_lineno or node.lineno
183
+ if start <= finding_line <= end:
184
+ size = end - start
185
+ if size < best_size:
186
+ best_size = size
187
+ best = (start, end)
188
+
189
+ return best if best else (finding_line, finding_line)
190
+
191
+
192
+ def apply_patch(
193
+ file_path: Path,
194
+ patch_code: str,
195
+ line_start: int,
196
+ line_end: int,
197
+ ) -> None:
198
+ """
199
+ Replace lines [line_start, line_end] (1-indexed, inclusive) in *file_path*
200
+ with *patch_code*, atomically via a .tmp file.
201
+
202
+ Raises:
203
+ OSError — file read/write failure
204
+ ValueError — patch would produce invalid Python
205
+ """
206
+ # Read current file
207
+ original_lines = file_path.read_text(encoding="utf-8").splitlines(keepends=True)
208
+
209
+ # Preserve leading indentation of the original block
210
+ # (the replaced block may be inside a class — keep its indentation)
211
+ original_block_indent = ""
212
+ if line_start <= len(original_lines):
213
+ first_original = original_lines[line_start - 1]
214
+ original_block_indent = first_original[: len(first_original) - len(first_original.lstrip())]
215
+
216
+ # Re-indent the patch_code to match the original indentation
217
+ patch_lines = patch_code.splitlines(keepends=True)
218
+ first_patch = patch_lines[0] if patch_lines else ""
219
+ patch_indent = first_patch[: len(first_patch) - len(first_patch.lstrip())]
220
+
221
+ if patch_indent != original_block_indent:
222
+ # Adjust: strip patch's own leading indent, add original's
223
+ adjusted = []
224
+ for pl in patch_lines:
225
+ stripped = pl.removeprefix(patch_indent)
226
+ adjusted.append(original_block_indent + stripped)
227
+ patch_lines = adjusted
228
+
229
+ # Assemble new file content
230
+ before = original_lines[: line_start - 1]
231
+ after = original_lines[line_end:]
232
+ new_content = "".join(before + patch_lines + after)
233
+
234
+ # Validate the new file as a whole
235
+ valid, err = validate_patch_syntax(new_content)
236
+ if not valid:
237
+ raise ValueError(f"Patched file has a syntax error: {err}")
238
+
239
+ # Atomic write via .tmp
240
+ tmp_path = file_path.with_suffix(".py.tmp")
241
+ try:
242
+ tmp_path.write_text(new_content, encoding="utf-8")
243
+ tmp_path.replace(file_path)
244
+ except OSError:
245
+ tmp_path.unlink(missing_ok=True)
246
+ raise
247
+
248
+ print_step_ok(
249
+ f"Patch applied to [cyan]{file_path.name}[/] (replaced lines {line_start}–{line_end})"
250
+ )
251
+
252
+
253
+ # ─────────────────────────────────────────────────────────────
254
+ # Rollback
255
+ # ─────────────────────────────────────────────────────────────
256
+
257
+
258
+ def rollback(file_path: Path, checkpoint: Path) -> None:
259
+ """Restore *file_path* from the checkpoint copy."""
260
+ print_step_running(f"Rolling back [cyan]{file_path.name}[/] ...")
261
+ try:
262
+ shutil.copy2(checkpoint, file_path)
263
+ print_step_ok("Rollback complete — original file restored")
264
+ except OSError as e:
265
+ print_step_fail(f"Rollback FAILED: {e} — manual recovery needed!")
266
+ print_warning(f"Checkpoint is at: {checkpoint}")
pisces/scan_cmd.py ADDED
@@ -0,0 +1,230 @@
1
+ """
2
+ pisces.scan_cmd
3
+ ~~~~~~~~~~~~~~~
4
+ Implements `pisces scan` — discovery, context update, scanning, output.
5
+
6
+ After scanning, findings are cached in .pisces/cache/findings.json so
7
+ `pisces explain` and `pisces fix` can resolve them by file:line reference.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from pisces.config_manager import load_config
16
+ from pisces.context_manager import ContextManager, discover_python_files
17
+ from pisces.models import Finding, Severity
18
+ from pisces.scanner import Scanner
19
+ from pisces.tui import (
20
+ console,
21
+ make_scan_progress,
22
+ print_findings_table,
23
+ print_section,
24
+ print_step_ok,
25
+ print_warning,
26
+ spinner,
27
+ )
28
+
29
+ # Where we persist findings between commands
30
+ _FINDINGS_CACHE = ".pisces/cache/findings.json"
31
+
32
+
33
+ # ─────────────────────────────────────────────────────────────
34
+ # Entry point
35
+ # ─────────────────────────────────────────────────────────────
36
+
37
+
38
+ def run_scan(
39
+ target: Path,
40
+ output: Path | None = None,
41
+ min_severity: str = "LOW",
42
+ ) -> int:
43
+ """
44
+ Entry point for `pisces scan`.
45
+
46
+ Returns 0 if no findings (or only below threshold), 1 if findings exist.
47
+ """
48
+ print_section("pisces Security Scanner")
49
+
50
+ # ── Load config ──────────────────────────────────────────
51
+ try:
52
+ cfg = load_config(target)
53
+ excludes = cfg.scan.exclude
54
+ except RuntimeError:
55
+ # Might be scanning a single file without init — allow it
56
+ excludes = []
57
+ print_warning("No pisces.toml found — using default exclusions.")
58
+
59
+ # ── Discover Python files ────────────────────────────────
60
+ with spinner("Discovering Python files..."):
61
+ if target.is_file() and target.suffix == ".py":
62
+ py_files = [target]
63
+ project_root = target.parent
64
+ else:
65
+ project_root = target
66
+ py_files = discover_python_files(target, extra_excludes=excludes)
67
+
68
+ print_step_ok(f"Discovered [cyan]{len(py_files)}[/] Python file(s)")
69
+
70
+ if not py_files:
71
+ console.print()
72
+ console.print("[muted]No Python files found to scan.[/]")
73
+ return 0
74
+
75
+ # ── Update context ───────────────────────────────────────
76
+ with spinner("Updating project context..."):
77
+ try:
78
+ cm = ContextManager(project_root, extra_excludes=excludes)
79
+ cm.update(verbose=False)
80
+ except (OSError, RuntimeError, TypeError, ValueError) as e:
81
+ print_warning(f"Context update skipped: {e}")
82
+
83
+ print_step_ok("Project context updated")
84
+
85
+ # ── Run security rules with progress bar ─────────────────
86
+ console.print()
87
+ scanner = Scanner()
88
+ all_findings: list[Finding] = []
89
+
90
+ _RULE_LABELS = [
91
+ "Checking hardcoded secrets",
92
+ "Checking command injection",
93
+ "Checking unsafe subprocess usage",
94
+ "Checking unsafe deserialization",
95
+ "Checking TLS verification",
96
+ "Checking code injection (eval/exec)",
97
+ "Checking filesystem operations",
98
+ ]
99
+
100
+ with make_scan_progress() as progress:
101
+ task = progress.add_task(
102
+ "Running security rules...", total=len(py_files) + len(_RULE_LABELS)
103
+ )
104
+
105
+ # Show rule-level steps briefly
106
+ for label in _RULE_LABELS:
107
+ progress.update(task, description=label, advance=1)
108
+
109
+ # Scan files
110
+ for py_file in py_files:
111
+ progress.update(
112
+ task,
113
+ description=f"Scanning [cyan]{py_file.name}[/]...",
114
+ advance=1,
115
+ )
116
+ all_findings.extend(scanner.scan_file(py_file, project_root))
117
+
118
+ console.print()
119
+ print_step_ok("Scan complete")
120
+ console.print()
121
+
122
+ # ── Filter by minimum severity ───────────────────────────
123
+ try:
124
+ min_sev = Severity.from_str(min_severity)
125
+ except (OSError, RuntimeError, TypeError, ValueError):
126
+ min_sev = Severity.LOW
127
+
128
+ _sev_order = {
129
+ Severity.CRITICAL: 0,
130
+ Severity.HIGH: 1,
131
+ Severity.MEDIUM: 2,
132
+ Severity.LOW: 3,
133
+ Severity.INFO: 4,
134
+ }
135
+ threshold = _sev_order[min_sev]
136
+ filtered = [f for f in all_findings if _sev_order.get(f.severity, 99) <= threshold]
137
+
138
+ # ── Save findings cache ───────────────────────────────────
139
+ _save_findings_cache(project_root, all_findings)
140
+
141
+ # ── Display results ───────────────────────────────────────
142
+ findings_as_dicts = [
143
+ {
144
+ "severity": f.severity.value,
145
+ "file": f.file,
146
+ "line": f.line,
147
+ "rule": f.rule,
148
+ "description": f.description,
149
+ }
150
+ for f in filtered
151
+ ]
152
+ print_findings_table(findings_as_dicts)
153
+
154
+ # ── Summary line ─────────────────────────────────────────
155
+ total = len(all_findings)
156
+ if total > 0:
157
+ above = len(filtered)
158
+ console.print(
159
+ f" Found [bold red]{total}[/] vulnerability/vulnerabilities total "
160
+ f"([cyan]{above}[/] at or above [bold]{min_severity}[/] severity).\n"
161
+ )
162
+
163
+ if filtered:
164
+ console.print(
165
+ " [dim]Run[/] [cyan]pisces explain <file>:<line>[/] "
166
+ "[dim]for detail on any finding.[/]"
167
+ )
168
+ console.print(
169
+ " [dim]Run[/] [cyan]pisces fix <file>:<line>[/] "
170
+ "[dim]to generate and apply a security patch.[/]"
171
+ )
172
+ console.print()
173
+
174
+ # ── Optional JSON output ──────────────────────────────────
175
+ if output:
176
+ _write_json_output(output, all_findings)
177
+
178
+ # Exit code: 1 if any findings at/above threshold, else 0
179
+ return 1 if filtered else 0
180
+
181
+
182
+ # ─────────────────────────────────────────────────────────────
183
+ # Findings cache (used by explain + fix commands)
184
+ # ─────────────────────────────────────────────────────────────
185
+
186
+
187
+ def _save_findings_cache(project_root: Path, findings: list[Finding]) -> None:
188
+ """Save findings to .pisces/cache/findings.json for lookup by other commands."""
189
+ cache_path = project_root / _FINDINGS_CACHE
190
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
191
+ try:
192
+ data = {
193
+ "findings": [f.to_dict() for f in findings],
194
+ # Index by "file:line" for O(1) lookup
195
+ "index": {f"{f.file}:{f.line}": f.to_dict() for f in findings},
196
+ }
197
+ cache_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
198
+ except OSError as e:
199
+ print_warning(f"Could not save findings cache: {e}")
200
+
201
+
202
+ def load_finding_by_ref(project_root: Path, ref: str) -> Finding | None:
203
+ """
204
+ Load a specific finding by its 'file:line' reference.
205
+
206
+ Called by `pisces explain` and `pisces fix`.
207
+ """
208
+ cache_path = project_root / _FINDINGS_CACHE
209
+ if not cache_path.exists():
210
+ return None
211
+ try:
212
+ data = json.loads(cache_path.read_text(encoding="utf-8"))
213
+ entry = data.get("index", {}).get(ref)
214
+ if entry:
215
+ return Finding.from_dict(entry)
216
+ except (json.JSONDecodeError, KeyError, OSError):
217
+ pass
218
+ return None
219
+
220
+
221
+ def _write_json_output(output: Path, findings: list[Finding]) -> None:
222
+ """Write findings as JSON to a user-specified file."""
223
+ try:
224
+ output.write_text(
225
+ json.dumps([f.to_dict() for f in findings], indent=2),
226
+ encoding="utf-8",
227
+ )
228
+ print_step_ok(f"Findings saved to [cyan]{output}[/]")
229
+ except OSError as e:
230
+ print_warning(f"Could not write output file: {e}")