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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
4
+ __email__ = "mrinmoymatilal1315@gmail.com"
5
+ __author__ = "Mrinmoy-Matilal"
6
+ __license__ = "MIT"
7
+ __description__ = "AI-assisted,self healing human-governed security remediation patcher"
8
+
9
+ __all__ = ["__version__"]
pisces/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pisces.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
pisces/ast_visitor.py ADDED
@@ -0,0 +1,347 @@
1
+ """
2
+ pisces.ast_visitor
3
+ ~~~~~~~~~~~~~~~~~~
4
+ AST-based code intelligence extractor.
5
+
6
+ Uses Python's stdlib `ast` module. Extracts structured information
7
+ without serialising raw AST nodes (those are huge and not useful).
8
+
9
+ What we extract per file:
10
+ - functions (name, lines, calls, args, async, decorators)
11
+ - classes (name, lines, bases, methods)
12
+ - imports (module, names, line)
13
+ - all calls (top-level call names for quick callee graph)
14
+ - security_constructs (patterns the scanner will refine further)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import textwrap
21
+ from pathlib import Path
22
+
23
+ from pisces.models import (
24
+ ClassInfo,
25
+ FileContext,
26
+ FunctionInfo,
27
+ ImportInfo,
28
+ SecurityConstruct,
29
+ )
30
+
31
+ # ─────────────────────────────────────────────────────────────
32
+ # Security-relevant call patterns
33
+ # ─────────────────────────────────────────────────────────────
34
+ # The scanner does the authoritative detection; the visitor just
35
+ # flags patterns of interest so the scanner knows where to look.
36
+
37
+ _SECURITY_CALLS: dict[str, str] = {
38
+ # subprocess
39
+ "subprocess.run": "subprocess_call",
40
+ "subprocess.call": "subprocess_call",
41
+ "subprocess.Popen": "subprocess_popen",
42
+ "subprocess.check_output": "subprocess_call",
43
+ "subprocess.check_call": "subprocess_call",
44
+ # os execution
45
+ "os.system": "os_system",
46
+ "os.popen": "os_popen",
47
+ "os.execv": "os_exec",
48
+ "os.execve": "os_exec",
49
+ # deserialization
50
+ "pickle.load": "pickle_load",
51
+ "pickle.loads": "pickle_load",
52
+ "pickle.Unpickler": "pickle_load",
53
+ "marshal.load": "marshal_load",
54
+ "marshal.loads": "marshal_load",
55
+ "yaml.load": "yaml_unsafe_load", # without Loader=safe
56
+ "shelve.open": "shelve_open",
57
+ # code execution
58
+ "eval": "eval_call",
59
+ "exec": "exec_call",
60
+ "compile": "compile_call",
61
+ "__import__": "dynamic_import",
62
+ # filesystem
63
+ "open": "file_open",
64
+ "shutil.rmtree": "dangerous_fs",
65
+ "os.remove": "dangerous_fs",
66
+ "os.unlink": "dangerous_fs",
67
+ # network / TLS
68
+ "ssl.wrap_socket": "ssl_call",
69
+ "urllib.request.urlopen": "url_open",
70
+ "requests.get": "requests_call",
71
+ "requests.post": "requests_call",
72
+ "httpx.get": "httpx_call",
73
+ "httpx.post": "httpx_call",
74
+ }
75
+
76
+
77
+ # ─────────────────────────────────────────────────────────────
78
+ # Helper: resolve a Call node to a dotted name string
79
+ # ─────────────────────────────────────────────────────────────
80
+
81
+
82
+ def _call_name(node: ast.Call) -> str:
83
+ """Return best-effort string name for a Call node, e.g. 'subprocess.run'."""
84
+ func = node.func
85
+ if isinstance(func, ast.Attribute):
86
+ value = func.value
87
+ if isinstance(value, ast.Name):
88
+ return f"{value.id}.{func.attr}"
89
+ if isinstance(value, ast.Attribute) and isinstance(value.value, ast.Name):
90
+ return f"{value.value.id}.{value.attr}.{func.attr}"
91
+ return func.attr
92
+ if isinstance(func, ast.Name):
93
+ return func.id
94
+ return ""
95
+
96
+
97
+ def _node_source(source_lines: list[str], node: ast.AST) -> str:
98
+ """Return a short (~120 char) source snippet for a node."""
99
+ lineno = getattr(node, "lineno", None)
100
+ if lineno is None or lineno > len(source_lines):
101
+ return ""
102
+ line = source_lines[lineno - 1].strip()
103
+ return line[:120]
104
+
105
+
106
+ # ─────────────────────────────────────────────────────────────
107
+ # Main visitor
108
+ # ─────────────────────────────────────────────────────────────
109
+
110
+
111
+ class PiscesASTVisitor(ast.NodeVisitor):
112
+ """
113
+ Walk a Python AST and extract structured intelligence.
114
+
115
+ Usage:
116
+ visitor = PiscesASTVisitor(source_lines)
117
+ visitor.visit(tree)
118
+ functions = visitor.functions
119
+ classes = visitor.classes
120
+ imports = visitor.imports
121
+ calls = visitor.calls
122
+ security = visitor.security_constructs
123
+ """
124
+
125
+ def __init__(self, source_lines: list[str]) -> None:
126
+ self._lines = source_lines
127
+ self.functions: list[FunctionInfo] = []
128
+ self.classes: list[ClassInfo] = []
129
+ self.imports: list[ImportInfo] = []
130
+ self.calls: list[str] = [] # deduplicated
131
+ self.security_constructs: list[SecurityConstruct] = []
132
+
133
+ self._seen_calls: set[str] = set()
134
+ # Track current function scope for nested call attribution
135
+ self._current_function: FunctionInfo | None = None
136
+
137
+ # ── Functions ─────────────────────────────────────────
138
+
139
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
140
+ self._process_function(node, is_async=False)
141
+
142
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
143
+ self._process_function(node, is_async=True)
144
+
145
+ def _process_function(
146
+ self,
147
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
148
+ is_async: bool,
149
+ ) -> None:
150
+ decorators = [ast.unparse(d) for d in node.decorator_list]
151
+ args = [a.arg for a in node.args.args]
152
+
153
+ # Docstring
154
+ docstring: str | None = None
155
+ if (
156
+ node.body
157
+ and isinstance(node.body[0], ast.Expr)
158
+ and isinstance(node.body[0].value, ast.Constant)
159
+ and isinstance(node.body[0].value.value, str)
160
+ ):
161
+ raw_doc = node.body[0].value.value
162
+ # Truncate long docstrings
163
+ docstring = textwrap.shorten(raw_doc, width=200, placeholder="...")
164
+
165
+ fn = FunctionInfo(
166
+ name=node.name,
167
+ line_start=node.lineno,
168
+ line_end=node.end_lineno or node.lineno,
169
+ is_async=is_async,
170
+ decorators=decorators,
171
+ args=args,
172
+ docstring=docstring,
173
+ )
174
+
175
+ prev = self._current_function
176
+ self._current_function = fn
177
+ self.generic_visit(node) # visit children → populates fn.calls
178
+ self._current_function = prev
179
+
180
+ self.functions.append(fn)
181
+
182
+ # ── Classes ───────────────────────────────────────────
183
+
184
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
185
+ bases = []
186
+ for b in node.bases:
187
+ try:
188
+ base = ast.unparse(b)
189
+ except (ValueError, RecursionError):
190
+ base = None
191
+ if base is not None:
192
+ bases.append(base)
193
+
194
+ decorators = [ast.unparse(d) for d in node.decorator_list]
195
+ methods = [
196
+ n.name for n in ast.walk(node) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
197
+ ]
198
+
199
+ cls = ClassInfo(
200
+ name=node.name,
201
+ line_start=node.lineno,
202
+ line_end=node.end_lineno or node.lineno,
203
+ bases=bases,
204
+ methods=methods,
205
+ decorators=decorators,
206
+ )
207
+ self.classes.append(cls)
208
+ self.generic_visit(node)
209
+
210
+ # ── Imports ───────────────────────────────────────────
211
+
212
+ def visit_Import(self, node: ast.Import) -> None:
213
+ for alias in node.names:
214
+ self.imports.append(
215
+ ImportInfo(
216
+ module=alias.name,
217
+ names=[alias.asname or alias.name],
218
+ line=node.lineno,
219
+ is_from=False,
220
+ )
221
+ )
222
+ self.generic_visit(node)
223
+
224
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
225
+ module = node.module or ""
226
+ names = [alias.asname or alias.name for alias in node.names]
227
+ self.imports.append(
228
+ ImportInfo(
229
+ module=module,
230
+ names=names,
231
+ line=node.lineno,
232
+ is_from=True,
233
+ )
234
+ )
235
+ self.generic_visit(node)
236
+
237
+ # ── Calls ─────────────────────────────────────────────
238
+
239
+ def visit_Call(self, node: ast.Call) -> None:
240
+ name = _call_name(node)
241
+ if not name:
242
+ self.generic_visit(node)
243
+ return
244
+
245
+ # Add to global deduplicated call set
246
+ if name not in self._seen_calls:
247
+ self._seen_calls.add(name)
248
+ self.calls.append(name)
249
+
250
+ # Add to current function's call list
251
+ if self._current_function is not None and name not in self._current_function.calls:
252
+ self._current_function.calls.append(name)
253
+
254
+ # Check for security-relevant patterns
255
+ kind = _SECURITY_CALLS.get(name)
256
+ if kind:
257
+ snippet = _node_source(self._lines, node)
258
+ self.security_constructs.append(
259
+ SecurityConstruct(
260
+ kind=kind,
261
+ line=node.lineno,
262
+ code=snippet,
263
+ )
264
+ )
265
+
266
+ self.generic_visit(node)
267
+
268
+ # ── String constants — hardcoded secret hints ─────────
269
+
270
+ def visit_Assign(self, node: ast.Assign) -> None:
271
+ """Flag assignments that may be hardcoded secrets."""
272
+ _SECRET_NAMES = {
273
+ "password",
274
+ "passwd",
275
+ "secret",
276
+ "api_key",
277
+ "apikey",
278
+ "token",
279
+ "auth_token",
280
+ "access_token",
281
+ "private_key",
282
+ "secret_key",
283
+ "db_password",
284
+ "database_password",
285
+ }
286
+ for target in node.targets:
287
+ if not isinstance(target, ast.Name):
288
+ continue
289
+ var_name = target.id.lower()
290
+ # Variable name looks like a secret
291
+ if (
292
+ any(s in var_name for s in _SECRET_NAMES)
293
+ and isinstance(node.value, ast.Constant)
294
+ and isinstance(node.value.value, str)
295
+ and node.value.value.strip()
296
+ ):
297
+ snippet = _node_source(self._lines, node)
298
+ self.security_constructs.append(
299
+ SecurityConstruct(
300
+ kind="hardcoded_secret_assignment",
301
+ line=node.lineno,
302
+ code=snippet,
303
+ details=f"Variable '{target.id}' assigned a string literal",
304
+ )
305
+ )
306
+ self.generic_visit(node)
307
+
308
+
309
+ # ─────────────────────────────────────────────────────────────
310
+ # Public extraction function
311
+ # ─────────────────────────────────────────────────────────────
312
+
313
+
314
+ def extract_file_context(
315
+ file_path: Path,
316
+ relative_path: str,
317
+ file_hash: str,
318
+ source: str,
319
+ ) -> FileContext:
320
+ """
321
+ Parse *source* with Python's ast module and return a FileContext.
322
+
323
+ If parsing fails (e.g. syntax error), returns a FileContext with
324
+ parse_error set — the scanner can still run regex rules on the raw source.
325
+ """
326
+ fc = FileContext(path=relative_path, hash=file_hash)
327
+ source_lines = source.splitlines()
328
+
329
+ try:
330
+ tree = ast.parse(source, filename=str(file_path))
331
+ except SyntaxError as e:
332
+ fc.parse_error = str(e)
333
+ return fc
334
+ except (TypeError, ValueError, RecursionError) as e:
335
+ fc.parse_error = f"Unexpected parse error: {e}"
336
+ return fc
337
+
338
+ visitor = PiscesASTVisitor(source_lines)
339
+ visitor.visit(tree)
340
+
341
+ fc.functions = visitor.functions
342
+ fc.classes = visitor.classes
343
+ fc.imports = visitor.imports
344
+ fc.calls = visitor.calls
345
+ fc.security_constructs = visitor.security_constructs
346
+
347
+ return fc
pisces/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ """Typer CLI entrypoint, all commands here actually. Only the commands, logics in another module"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from pisces import __version__
11
+ from pisces.tui import console, print_banner
12
+
13
+ app = typer.Typer(
14
+ name="pisces",
15
+ help=(
16
+ "[bold magenta]Pisces[/]-AI-assisted,self healing"
17
+ "human-governed security remediation patcher.\n\n"
18
+ "Workflow: [cyan]scan → explain → fix → validate → self-heal[/]"
19
+ ),
20
+ rich_markup_mode="rich",
21
+ pretty_exceptions_show_locals=False,
22
+ no_args_is_help=False,
23
+ invoke_without_command=True,
24
+ add_completion=True,
25
+ )
26
+
27
+
28
+ def _version_callback(value: bool) -> None:
29
+ if value:
30
+ console.print(f"[bold magenta]Pisces[/] [dim]v{__version__}[/]")
31
+ raise typer.Exit()
32
+
33
+
34
+ @app.callback()
35
+ def main(
36
+ ctx: typer.Context,
37
+ version: Annotated[
38
+ bool | None,
39
+ typer.Option(
40
+ "--version",
41
+ "-v",
42
+ help="Show version and exit.",
43
+ callback=_version_callback,
44
+ is_eager=True,
45
+ ),
46
+ ] = None,
47
+ ) -> None:
48
+ if ctx.invoked_subcommand is None:
49
+ print_banner()
50
+ console.print(ctx.get_help())
51
+
52
+
53
+ """init command"""
54
+
55
+
56
+ @app.command()
57
+ def init(
58
+ path: Annotated[
59
+ Path,
60
+ typer.Argument(help="Project root to be initialized."),
61
+ ] = Path("."),
62
+ force: Annotated[
63
+ bool, typer.Option("--force", "-f", help="Overwrite existing pisces.toml")
64
+ ] = False,
65
+ ) -> None:
66
+
67
+ print_banner()
68
+ from pisces.init_cmd import run_init
69
+
70
+ raise typer.Exit(run_init(path.resolve(), force=force))
71
+
72
+
73
+ """config commands"""
74
+
75
+
76
+ @app.command()
77
+ def config(
78
+ path: Annotated[Path, typer.Argument(help="Project root directory")] = Path("."),
79
+ show: Annotated[
80
+ bool,
81
+ typer.Option("--show", "-s", help="Print current configuration and exit."),
82
+ ] = False,
83
+ ) -> None:
84
+
85
+ print_banner()
86
+ from pisces.config_cmd import run_config
87
+
88
+ raise typer.Exit(run_config(path.resolve(), show_only=show))
89
+
90
+
91
+ """Scan command"""
92
+
93
+
94
+ @app.command()
95
+ def scan(
96
+ target: Annotated[
97
+ Path,
98
+ typer.Argument(help="Directory or file to scan."),
99
+ ] = Path("."),
100
+ output: Annotated[
101
+ Path | None,
102
+ typer.Option("--output", "-o", help="Save the findings to a JSON file"),
103
+ ] = None,
104
+ min_severity: Annotated[
105
+ str,
106
+ typer.Option(
107
+ "--min-severity",
108
+ "-s",
109
+ help="Minimum severity to report (CRITICAL/HIGH/MEDIUM/LOW/INFO).",
110
+ ),
111
+ ] = "LOW",
112
+ ) -> None:
113
+ """Scan Python files for security vulnerabilities."""
114
+ print_banner()
115
+ from pisces.scan_cmd import run_scan
116
+
117
+ raise typer.Exit(run_scan(target.resolve(), output=output, min_severity=min_severity))
118
+
119
+
120
+ """explain command"""
121
+
122
+
123
+ @app.command()
124
+ def explain(
125
+ finding: Annotated[
126
+ str,
127
+ typer.Argument(help="Finding reference, e.g. auth.py:42."),
128
+ ],
129
+ ) -> None:
130
+ """Explain a security finding using the configured LLM."""
131
+ print_banner()
132
+
133
+ from pisces.explain_cmd import run_explain
134
+
135
+ raise typer.Exit(
136
+ run_explain(
137
+ finding,
138
+ project_root=Path.cwd(),
139
+ )
140
+ )
141
+
142
+
143
+ """Fix command"""
144
+
145
+
146
+ @app.command()
147
+ def fix(
148
+ finding: Annotated[
149
+ str,
150
+ typer.Argument(help="Finding reference, e.g. auth.py:45."),
151
+ ],
152
+ dry_run: Annotated[
153
+ bool,
154
+ typer.Option(
155
+ "--dry-run",
156
+ help="Show the proposed patch without modifying the file.",
157
+ ),
158
+ ] = False,
159
+ no_judge: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--no-judge",
163
+ help="Skip LLM judge evaluation (not recommended).",
164
+ ),
165
+ ] = False,
166
+ max_attempts: Annotated[
167
+ int | None,
168
+ typer.Option(
169
+ "--max-attempts",
170
+ "-m",
171
+ help="Override the maximum self-healing attempts.",
172
+ min=1,
173
+ ),
174
+ ] = None,
175
+ ) -> None:
176
+ """Generate, review, apply, and validate a security patch."""
177
+ print_banner()
178
+
179
+ from pisces.fix_cmd import run_fix
180
+
181
+ raise typer.Exit(
182
+ run_fix(
183
+ finding,
184
+ project_root=Path.cwd(),
185
+ dry_run=dry_run,
186
+ skip_judge=no_judge,
187
+ max_attempts=max_attempts,
188
+ )
189
+ )
190
+
191
+
192
+ """watch command"""
193
+
194
+
195
+ @app.command()
196
+ def watch(
197
+ path: Annotated[
198
+ Path,
199
+ typer.Argument(help="Directory to watch."),
200
+ ] = Path("."),
201
+ ) -> None:
202
+ """Watch for Python file changes and auto-update project context. Uses [cyan]watchdog[/] for filesystem events"""
203
+
204
+ print_banner()
205
+ from pisces.watcher import run_watch
206
+
207
+ try:
208
+ run_watch(path.resolve())
209
+ except KeyboardInterrupt:
210
+ console.print("\n[muted]Watch mode stopped.[/]")
211
+ raise typer.Exit(0)
212
+
213
+ """History command"""
214
+
215
+
216
+ @app.command()
217
+ def history(
218
+ path: Annotated[
219
+ Path,
220
+ typer.Argument(help="Project root directory."),
221
+ ] = Path("."),
222
+ detail: Annotated[
223
+ str | None,
224
+ typer.Option("--detail", "-d", help="Show detail for a specific repair ID."),
225
+ ] = None,
226
+ limit: Annotated[
227
+ int,
228
+ typer.Option("--limit", "-n", help="Number of records to show."),
229
+ ] = 20,
230
+ ) -> None:
231
+ """Show repair history for this project."""
232
+ print_banner()
233
+ from pisces.history_cmd import run_history
234
+
235
+ raise typer.Exit(
236
+ run_history(
237
+ path.resolve(),
238
+ repair_id=detail,
239
+ limit=limit,
240
+ )
241
+ )
242
+
243
+
244
+ if __name__ == "__main__":
245
+ app()