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 +9 -0
- pisces/__main__.py +4 -0
- pisces/ast_visitor.py +347 -0
- pisces/cli.py +245 -0
- pisces/config_cmd.py +234 -0
- pisces/config_manager.py +208 -0
- pisces/context_manager.py +333 -0
- pisces/explain_cmd.py +608 -0
- pisces/fix_cmd.py +479 -0
- pisces/history_cmd.py +313 -0
- pisces/init_cmd.py +193 -0
- pisces/judge.py +131 -0
- pisces/llm_client.py +263 -0
- pisces/models.py +413 -0
- pisces/patch_engine.py +266 -0
- pisces/scan_cmd.py +230 -0
- pisces/scanner.py +677 -0
- pisces/tui.py +539 -0
- pisces/watcher.py +244 -0
- pisces_cli-0.1.0.dist-info/METADATA +448 -0
- pisces_cli-0.1.0.dist-info/RECORD +24 -0
- pisces_cli-0.1.0.dist-info/WHEEL +4 -0
- pisces_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pisces_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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()
|