pwnguard 0.2.4__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.
- pwnguard/__init__.py +8 -0
- pwnguard/__main__.py +4 -0
- pwnguard/anchors.py +192 -0
- pwnguard/backends/__init__.py +73 -0
- pwnguard/backends/claude_api.py +74 -0
- pwnguard/backends/claude_code.py +73 -0
- pwnguard/backends/ollama.py +201 -0
- pwnguard/backends/openai_compat.py +343 -0
- pwnguard/cli.py +543 -0
- pwnguard/config.py +147 -0
- pwnguard/constants.py +109 -0
- pwnguard/data/__init__.py +0 -0
- pwnguard/data/pre-commit +65 -0
- pwnguard/diff.py +366 -0
- pwnguard/fetchers.py +413 -0
- pwnguard/installer.py +136 -0
- pwnguard/models.py +100 -0
- pwnguard/monitor.py +1086 -0
- pwnguard/parser.py +271 -0
- pwnguard/prompts.py +252 -0
- pwnguard/render.py +720 -0
- pwnguard/report.py +195 -0
- pwnguard/review.py +260 -0
- pwnguard/runtime.py +61 -0
- pwnguard/scan.py +404 -0
- pwnguard/security.py +44 -0
- pwnguard/tui.py +239 -0
- pwnguard/ui.py +523 -0
- pwnguard-0.2.4.dist-info/METADATA +692 -0
- pwnguard-0.2.4.dist-info/RECORD +33 -0
- pwnguard-0.2.4.dist-info/WHEEL +4 -0
- pwnguard-0.2.4.dist-info/entry_points.txt +2 -0
- pwnguard-0.2.4.dist-info/licenses/LICENSE +34 -0
pwnguard/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""PwnGuard: AI-powered security review for git workflows.
|
|
2
|
+
|
|
3
|
+
Implementation modules. The legacy ``audit`` module at the repo root
|
|
4
|
+
re-exports the public surface so existing callers (the pre-commit
|
|
5
|
+
hook, the README CLI examples, the test suite) keep working unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.4"
|
pwnguard/__main__.py
ADDED
pwnguard/anchors.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Opaque anchor token tagging and resolution.
|
|
2
|
+
|
|
3
|
+
Replaces fuzzy line-number / snippet matching: each ``+`` (added) and
|
|
4
|
+
context (`` ``) line in the diff is prefixed with ``[a<N>]``. The
|
|
5
|
+
model is told to echo the bare token back in each finding's
|
|
6
|
+
``anchor`` field; the host resolves the token via the table built
|
|
7
|
+
during tagging.
|
|
8
|
+
|
|
9
|
+
Why opaque tokens beat plain line numbers: they have no numeric
|
|
10
|
+
semantics the model can drift on, so they can only be copied
|
|
11
|
+
verbatim, not regenerated from "where this probably is in the file."
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from pwnguard.constants import DIFF_WRAPPER_OPEN, DIFF_WRAPPER_CLOSE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def wrap_diff(diff: str) -> tuple:
|
|
21
|
+
"""Wrap diff in delimiters, tag content lines with anchor tokens, and
|
|
22
|
+
return both the wrapped text and an anchor lookup table.
|
|
23
|
+
|
|
24
|
+
Each ``+`` (added) and context (`` ``) content line is prefixed
|
|
25
|
+
with an opaque token of the form ``[a<N>]`` (e.g. ``[a1]``,
|
|
26
|
+
``[a42]``). The model is told to echo the bare token back in each
|
|
27
|
+
finding's ``anchor`` field; the returned table maps each token to
|
|
28
|
+
``(file, line, content, kind, hunk_context)`` so post-parse
|
|
29
|
+
resolution is a single dict lookup. No counting, no fuzzy quote
|
|
30
|
+
matching, no function-name regex fallback.
|
|
31
|
+
|
|
32
|
+
Opaque tokens beat the old 5-digit line-number prefix because
|
|
33
|
+
they have no numeric semantics the model can drift on - they can
|
|
34
|
+
only be copied verbatim, not regenerated from "where this
|
|
35
|
+
probably is in the file". The namespace resets per call, which
|
|
36
|
+
is fine because each AI request is parsed against its own table.
|
|
37
|
+
|
|
38
|
+
Diff metadata (file headers, hunk headers, removed lines) is left
|
|
39
|
+
untagged: those don't correspond to a new-file position.
|
|
40
|
+
|
|
41
|
+
Returns ``(wrapped_text, anchor_table)`` where ``anchor_table`` is
|
|
42
|
+
a ``dict[str, dict]`` keyed by the bare token (no brackets).
|
|
43
|
+
"""
|
|
44
|
+
body, anchors = _anchor_diff_lines(diff)
|
|
45
|
+
wrapped = f"{DIFF_WRAPPER_OPEN}\n{body}\n{DIFF_WRAPPER_CLOSE}"
|
|
46
|
+
return wrapped, anchors
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _anchor_diff_lines(diff: str) -> tuple:
|
|
50
|
+
"""Walk the diff once, emitting ``[a<N>]`` tokens and building the
|
|
51
|
+
anchor table.
|
|
52
|
+
|
|
53
|
+
Replaces the old ``_number_diff_lines`` line-number prefixer. The
|
|
54
|
+
token namespace is a simple incrementing counter (``a1``, ``a2``,
|
|
55
|
+
...) reset on every call. Removed (``-``) lines, file headers,
|
|
56
|
+
hunk headers, and the ``diff --git`` line stay untagged - the
|
|
57
|
+
model can't anchor a finding to them, so giving them a token
|
|
58
|
+
would only invite hallucinated references.
|
|
59
|
+
"""
|
|
60
|
+
out = []
|
|
61
|
+
anchors = {}
|
|
62
|
+
next_id = 1
|
|
63
|
+
current_file: Optional[str] = None
|
|
64
|
+
current_lineno = 0
|
|
65
|
+
current_hunk_context: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
# ``git diff`` always emits a trailing newline; ``split("\n")`` then
|
|
68
|
+
# yields a trailing "" that isn't a real content line. Drop it so we
|
|
69
|
+
# don't tag a phantom context anchor past the last real line (which
|
|
70
|
+
# the model could then pick, resolving to an empty-content row).
|
|
71
|
+
lines = diff.split("\n")
|
|
72
|
+
if lines and lines[-1] == "":
|
|
73
|
+
lines = lines[:-1]
|
|
74
|
+
|
|
75
|
+
for line in lines:
|
|
76
|
+
if line.startswith("+++ b/"):
|
|
77
|
+
current_file = line[6:]
|
|
78
|
+
current_lineno = 0
|
|
79
|
+
current_hunk_context = None
|
|
80
|
+
out.append(line)
|
|
81
|
+
continue
|
|
82
|
+
if line.startswith("@@"):
|
|
83
|
+
m = re.match(
|
|
84
|
+
r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@\s*(.*)",
|
|
85
|
+
line,
|
|
86
|
+
)
|
|
87
|
+
if m:
|
|
88
|
+
current_lineno = int(m.group(1)) - 1
|
|
89
|
+
ctx = m.group(2).strip()
|
|
90
|
+
current_hunk_context = ctx or None
|
|
91
|
+
out.append(line)
|
|
92
|
+
continue
|
|
93
|
+
# Until we've seen a `+++ b/<file>` header we don't know what
|
|
94
|
+
# file a line belongs to. Skip tagging in that state - emitting
|
|
95
|
+
# tokens with file="" would let the model anchor findings to a
|
|
96
|
+
# blank file and produce findings with no path / no preview
|
|
97
|
+
# (the typical symptom of feeding a non-diff to --diff-file).
|
|
98
|
+
if current_file is None:
|
|
99
|
+
out.append(line)
|
|
100
|
+
continue
|
|
101
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
102
|
+
current_lineno += 1
|
|
103
|
+
tok = f"a{next_id}"
|
|
104
|
+
next_id += 1
|
|
105
|
+
anchors[tok] = {
|
|
106
|
+
"file": current_file,
|
|
107
|
+
"line": current_lineno,
|
|
108
|
+
"content": line[1:],
|
|
109
|
+
"kind": "added",
|
|
110
|
+
"hunk_context": current_hunk_context,
|
|
111
|
+
}
|
|
112
|
+
out.append(f"[{tok}] {line}")
|
|
113
|
+
continue
|
|
114
|
+
if line.startswith(" ") or line == "":
|
|
115
|
+
current_lineno += 1
|
|
116
|
+
tok = f"a{next_id}"
|
|
117
|
+
next_id += 1
|
|
118
|
+
anchors[tok] = {
|
|
119
|
+
"file": current_file,
|
|
120
|
+
"line": current_lineno,
|
|
121
|
+
"content": line[1:] if line else "",
|
|
122
|
+
"kind": "context",
|
|
123
|
+
"hunk_context": current_hunk_context,
|
|
124
|
+
}
|
|
125
|
+
out.append(f"[{tok}] {line}")
|
|
126
|
+
continue
|
|
127
|
+
# `-` removed lines, `---` headers, `diff --git ...`, etc.
|
|
128
|
+
out.append(line)
|
|
129
|
+
|
|
130
|
+
return "\n".join(out), anchors
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_anchors(result, anchor_table: dict) -> int:
|
|
134
|
+
"""Resolve each finding's / observation's ``anchor`` token to a real
|
|
135
|
+
location, rewriting ``file``, ``line``, and ``hunk_context``.
|
|
136
|
+
|
|
137
|
+
Returns the number of items dropped because their anchor was
|
|
138
|
+
unknown - "loud failure" rather than fuzzy recovery. The host
|
|
139
|
+
should surface the count on stderr so the user sees that a
|
|
140
|
+
fabricated anchor occurred.
|
|
141
|
+
|
|
142
|
+
Resolution rules per finding/observation:
|
|
143
|
+
|
|
144
|
+
- Anchor present AND in table: rewrite ``file`` / ``line`` /
|
|
145
|
+
``hunk_context`` from the table entry. Trust the table.
|
|
146
|
+
- Anchor present but NOT in table: model fabricated an anchor.
|
|
147
|
+
Drop the item; do not try fuzzy matching - that's exactly the
|
|
148
|
+
pre-anchor fallback chain we're replacing.
|
|
149
|
+
- Anchor absent: file-level carve-out. Keep the item, leave
|
|
150
|
+
``file`` as the model supplied it, ``line`` stays None.
|
|
151
|
+
|
|
152
|
+
The previous pipeline (``_anchor_findings_by_snippet`` etc.) is
|
|
153
|
+
gone; one O(1) lookup replaces the whole repair chain.
|
|
154
|
+
"""
|
|
155
|
+
dropped = 0
|
|
156
|
+
kept_findings = []
|
|
157
|
+
for f in result.findings:
|
|
158
|
+
if f.anchor is None:
|
|
159
|
+
# File-level carve-out: model deliberately omitted anchor
|
|
160
|
+
# and supplied "file" directly. Keep if file is set;
|
|
161
|
+
# otherwise drop (no way to render it).
|
|
162
|
+
if f.file:
|
|
163
|
+
kept_findings.append(f)
|
|
164
|
+
else:
|
|
165
|
+
dropped += 1
|
|
166
|
+
continue
|
|
167
|
+
entry = anchor_table.get(f.anchor)
|
|
168
|
+
if not entry:
|
|
169
|
+
dropped += 1
|
|
170
|
+
continue
|
|
171
|
+
f.file = entry["file"]
|
|
172
|
+
f.line = entry["line"]
|
|
173
|
+
f.hunk_context = entry.get("hunk_context")
|
|
174
|
+
kept_findings.append(f)
|
|
175
|
+
result.findings = kept_findings
|
|
176
|
+
|
|
177
|
+
kept_obs = []
|
|
178
|
+
for o in result.observations:
|
|
179
|
+
if o.anchor is None:
|
|
180
|
+
kept_obs.append(o)
|
|
181
|
+
continue
|
|
182
|
+
entry = anchor_table.get(o.anchor)
|
|
183
|
+
if not entry:
|
|
184
|
+
# Drop the observation silently - we don't count these in
|
|
185
|
+
# the "dropped" tally because they're opt-in informational.
|
|
186
|
+
continue
|
|
187
|
+
o.file = entry["file"]
|
|
188
|
+
o.line = entry["line"]
|
|
189
|
+
kept_obs.append(o)
|
|
190
|
+
result.observations = kept_obs
|
|
191
|
+
|
|
192
|
+
return dropped
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""AI backend dispatch.
|
|
2
|
+
|
|
3
|
+
Each backend lives in its own submodule (one ``query_X`` function plus
|
|
4
|
+
any backend-specific helpers). ``dispatch_backend`` is the single
|
|
5
|
+
entry point used by ``scan`` and ``monitor`` so the wrap-diff /
|
|
6
|
+
anchor-table contract is enforced in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from pwnguard import runtime
|
|
12
|
+
from pwnguard.anchors import wrap_diff
|
|
13
|
+
from pwnguard.prompts import build_system_prompt
|
|
14
|
+
|
|
15
|
+
from pwnguard.backends.claude_api import query_claude_api
|
|
16
|
+
from pwnguard.backends.claude_code import claude_code_available, query_claude_code
|
|
17
|
+
from pwnguard.backends.ollama import query_ollama
|
|
18
|
+
from pwnguard.backends.openai_compat import query_openai_compat
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"claude_code_available",
|
|
23
|
+
"dispatch_backend",
|
|
24
|
+
"query_claude_api",
|
|
25
|
+
"query_claude_code",
|
|
26
|
+
"query_ollama",
|
|
27
|
+
"query_openai_compat",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dispatch_backend(
|
|
32
|
+
backend: str,
|
|
33
|
+
diff: str,
|
|
34
|
+
config: dict,
|
|
35
|
+
system_prompt: Optional[str] = None,
|
|
36
|
+
pre_wrapped: bool = False,
|
|
37
|
+
) -> tuple:
|
|
38
|
+
"""Run the requested backend. Centralizes the dispatch logic.
|
|
39
|
+
|
|
40
|
+
Wraps ``diff`` (assigning anchor tokens) and ships only the
|
|
41
|
+
wrapped text to the backend; returns ``(response, anchor_table)``
|
|
42
|
+
so the caller can resolve each finding's ``anchor`` field with a
|
|
43
|
+
single dict lookup.
|
|
44
|
+
|
|
45
|
+
Set ``pre_wrapped=True`` when the caller has already embedded a
|
|
46
|
+
wrapped diff inside its own custom ``system_prompt`` (the
|
|
47
|
+
--explain path is the only current case). In that mode the
|
|
48
|
+
anchor table comes back empty - explain is a re-query for one
|
|
49
|
+
already-resolved finding, so it doesn't need anchors.
|
|
50
|
+
|
|
51
|
+
When no system_prompt is given, builds one from the current code-preview
|
|
52
|
+
setting: if the caller won't render fix_example, we don't ask the model
|
|
53
|
+
to generate it (saves a few hundred tokens of prompt + output time on
|
|
54
|
+
7B local models).
|
|
55
|
+
"""
|
|
56
|
+
if system_prompt is None:
|
|
57
|
+
system_prompt = build_system_prompt(
|
|
58
|
+
include_preview_fields=runtime.show_code_preview,
|
|
59
|
+
include_observations=runtime.show_observations,
|
|
60
|
+
)
|
|
61
|
+
if pre_wrapped:
|
|
62
|
+
wrapped, anchors = diff, {}
|
|
63
|
+
else:
|
|
64
|
+
wrapped, anchors = wrap_diff(diff)
|
|
65
|
+
if backend == "claude-api":
|
|
66
|
+
response = query_claude_api(wrapped, config, system_prompt)
|
|
67
|
+
elif backend == "claude-code":
|
|
68
|
+
response = query_claude_code(wrapped, config, system_prompt)
|
|
69
|
+
elif backend == "openai-compat":
|
|
70
|
+
response = query_openai_compat(wrapped, config, system_prompt)
|
|
71
|
+
else:
|
|
72
|
+
response = query_ollama(wrapped, config, system_prompt)
|
|
73
|
+
return response, anchors
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Anthropic API backend (requires ``ANTHROPIC_API_KEY``).
|
|
2
|
+
|
|
3
|
+
This is the only paid path PwnGuard supports directly, so the
|
|
4
|
+
"large prompt" confirmation gate lives here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from pwnguard import ui
|
|
11
|
+
from pwnguard.constants import LARGE_PROMPT_TOKEN_THRESHOLD
|
|
12
|
+
from pwnguard.diff import estimate_tokens
|
|
13
|
+
from pwnguard.prompts import SYSTEM_PROMPT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def maybe_confirm_large_prompt(prompt: str, backend: str) -> None:
|
|
17
|
+
"""Warn before sending a very large prompt to a paid backend.
|
|
18
|
+
|
|
19
|
+
Only prompts for confirmation on the claude-api backend (the one with
|
|
20
|
+
direct per-token cost) and only when the prompt clearly crosses our
|
|
21
|
+
arbitrary "this is big" threshold. The user can disable the prompt by
|
|
22
|
+
setting PWNGUARD_NO_PROMPT=1 (e.g. for non-interactive scripts).
|
|
23
|
+
"""
|
|
24
|
+
if backend != "claude-api":
|
|
25
|
+
return
|
|
26
|
+
if os.environ.get("PWNGUARD_NO_PROMPT") == "1":
|
|
27
|
+
return
|
|
28
|
+
tokens = estimate_tokens(prompt)
|
|
29
|
+
if tokens < LARGE_PROMPT_TOKEN_THRESHOLD:
|
|
30
|
+
return
|
|
31
|
+
if not sys.stdin.isatty():
|
|
32
|
+
# Non-interactive (CI) - log the size but don't block.
|
|
33
|
+
print(
|
|
34
|
+
ui.dim(f"PwnGuard: large prompt (~{tokens:,} tokens)."),
|
|
35
|
+
file=sys.stderr,
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
print(
|
|
39
|
+
f"\nPwnGuard: estimated ~{tokens:,} input tokens for this scan.",
|
|
40
|
+
file=sys.stderr,
|
|
41
|
+
)
|
|
42
|
+
answer = input("Send to claude-api anyway? [y/N] ").strip().lower()
|
|
43
|
+
if answer != "y":
|
|
44
|
+
sys.exit("Aborted by user.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def query_claude_api(diff: str, config: dict, system_prompt: str = SYSTEM_PROMPT) -> str:
|
|
48
|
+
"""Send diff to Claude API for analysis (requires ANTHROPIC_API_KEY)."""
|
|
49
|
+
try:
|
|
50
|
+
import anthropic
|
|
51
|
+
except ImportError:
|
|
52
|
+
sys.exit("Error: 'anthropic' package not installed. Run: pip install anthropic")
|
|
53
|
+
|
|
54
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
55
|
+
if not api_key:
|
|
56
|
+
sys.exit("Error: ANTHROPIC_API_KEY environment variable not set")
|
|
57
|
+
|
|
58
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
59
|
+
claude_config = config.get("claude_api", {})
|
|
60
|
+
|
|
61
|
+
user_content = f"Review this git diff for security vulnerabilities:\n\n{diff}"
|
|
62
|
+
maybe_confirm_large_prompt(system_prompt + user_content, backend="claude-api")
|
|
63
|
+
|
|
64
|
+
message = client.messages.create(
|
|
65
|
+
model=claude_config.get("model", "claude-opus-4-7"),
|
|
66
|
+
max_tokens=claude_config.get("max_tokens", 4096),
|
|
67
|
+
system=system_prompt,
|
|
68
|
+
messages=[{"role": "user", "content": user_content}],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Guard against an empty content list (refusals, safety stops).
|
|
72
|
+
if not message.content:
|
|
73
|
+
return '{"findings": []}'
|
|
74
|
+
return message.content[0].text
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Claude Code CLI backend (uses the user's Pro subscription).
|
|
2
|
+
|
|
3
|
+
The CLI is shelled out and the prompt is fed via stdin. Detection is
|
|
4
|
+
cached so we don't probe ``claude --version`` on every invocation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from pwnguard.prompts import SYSTEM_PROMPT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Cache so we don't shell out to `claude --version` more than once per run.
|
|
15
|
+
_claude_code_available: Optional[bool] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def claude_code_available() -> bool:
|
|
19
|
+
"""Detect whether the `claude` CLI is installed. Cached after first call."""
|
|
20
|
+
global _claude_code_available
|
|
21
|
+
if _claude_code_available is not None:
|
|
22
|
+
return _claude_code_available
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["claude", "--version"],
|
|
26
|
+
capture_output=True, text=True, timeout=5,
|
|
27
|
+
)
|
|
28
|
+
_claude_code_available = result.returncode == 0
|
|
29
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
30
|
+
_claude_code_available = False
|
|
31
|
+
return _claude_code_available
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def query_claude_code(diff: str, config: dict, system_prompt: str = SYSTEM_PROMPT) -> str:
|
|
35
|
+
"""Send diff to Claude Code CLI for analysis (uses Pro subscription)."""
|
|
36
|
+
if not claude_code_available():
|
|
37
|
+
sys.exit(
|
|
38
|
+
"Error: 'claude' CLI not found. "
|
|
39
|
+
"Install Claude Code: https://docs.anthropic.com/en/docs/claude-code"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
cc_config = config.get("claude_code", {})
|
|
43
|
+
timeout = cc_config.get("timeout", 120)
|
|
44
|
+
|
|
45
|
+
# Combine system prompt and user prompt for -p mode. ``diff`` arrives
|
|
46
|
+
# pre-wrapped (in <diff_to_review>...</diff_to_review> with anchor
|
|
47
|
+
# tokens) from dispatch_backend; the system prompt's input-format
|
|
48
|
+
# rules already cover that envelope.
|
|
49
|
+
full_prompt = (
|
|
50
|
+
f"{system_prompt}\n\n"
|
|
51
|
+
f"Review this git diff for security vulnerabilities:\n\n{diff}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["claude", "-p", "--output-format", "text"],
|
|
57
|
+
input=full_prompt,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
)
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
sys.exit(f"Error: Claude Code timed out after {timeout}s")
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
sys.exit("Error: 'claude' command not found in PATH")
|
|
66
|
+
|
|
67
|
+
if result.returncode != 0:
|
|
68
|
+
msg = f"Error: Claude Code returned exit code {result.returncode}"
|
|
69
|
+
if result.stderr:
|
|
70
|
+
msg += f"\nstderr: {result.stderr[:500]}"
|
|
71
|
+
sys.exit(msg)
|
|
72
|
+
|
|
73
|
+
return result.stdout
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Local Ollama backend.
|
|
2
|
+
|
|
3
|
+
Refuses to ship the diff to a non-loopback host unless the user has
|
|
4
|
+
explicitly opted in (``ollama.allow_remote: true``) - otherwise an
|
|
5
|
+
attacker-controlled ``pwnguard.yaml`` could redirect every diff to
|
|
6
|
+
an external endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from pwnguard import runtime, ui
|
|
17
|
+
from pwnguard.constants import SAFE_OLLAMA_HOSTS
|
|
18
|
+
from pwnguard.prompts import SYSTEM_PROMPT
|
|
19
|
+
from pwnguard.security import _sanitize
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def query_ollama(diff: str, config: dict, system_prompt: str = SYSTEM_PROMPT) -> str:
|
|
23
|
+
"""Send diff to local Ollama instance for analysis."""
|
|
24
|
+
ollama_config = config.get("ollama", {})
|
|
25
|
+
url = ollama_config.get("url", "http://localhost:11434")
|
|
26
|
+
model = ollama_config.get("model", "qwen2.5-coder:7b")
|
|
27
|
+
allow_remote = ollama_config.get("allow_remote", False)
|
|
28
|
+
timeout = ollama_config.get("timeout", 600)
|
|
29
|
+
|
|
30
|
+
# Refuse to send the diff to a non-local host unless explicitly opted in.
|
|
31
|
+
# Otherwise a committed pwnguard.yaml pointing ollama.url at an external
|
|
32
|
+
# endpoint would silently exfiltrate every diff at the next CI run.
|
|
33
|
+
host = urllib.parse.urlparse(url).hostname or ""
|
|
34
|
+
if host not in SAFE_OLLAMA_HOSTS and not allow_remote:
|
|
35
|
+
sys.exit(
|
|
36
|
+
f"Refusing to send diff to non-local Ollama host: {host!r}.\n"
|
|
37
|
+
f"Set ollama.allow_remote: true in pwnguard.yaml to override "
|
|
38
|
+
f"(you accept that diffs leave the local machine)."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
payload_dict = {
|
|
42
|
+
"model": model,
|
|
43
|
+
"stream": False,
|
|
44
|
+
"messages": [
|
|
45
|
+
{"role": "system", "content": system_prompt},
|
|
46
|
+
{
|
|
47
|
+
"role": "user",
|
|
48
|
+
"content": f"Review this git diff for security vulnerabilities:\n\n{diff}",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
# Ollama's "format": "json" mode constrains every generated token to
|
|
53
|
+
# produce valid JSON. Reliable but slow (~2x on 7B). When the user
|
|
54
|
+
# opts out, parse_response's regex-extract + escape-fix fallbacks
|
|
55
|
+
# are what catch any non-JSON preamble / markdown fences.
|
|
56
|
+
if runtime.ollama_json_mode:
|
|
57
|
+
payload_dict["format"] = "json"
|
|
58
|
+
|
|
59
|
+
# Forward optional model tunables. Each is opt-in via config so the
|
|
60
|
+
# default behaviour matches whatever the model's own defaults are.
|
|
61
|
+
keep_alive = ollama_config.get("keep_alive")
|
|
62
|
+
if keep_alive:
|
|
63
|
+
payload_dict["keep_alive"] = keep_alive
|
|
64
|
+
options = {}
|
|
65
|
+
for opt_key in ("num_ctx", "num_predict", "temperature", "seed"):
|
|
66
|
+
if opt_key in ollama_config:
|
|
67
|
+
options[opt_key] = ollama_config[opt_key]
|
|
68
|
+
if options:
|
|
69
|
+
payload_dict["options"] = options
|
|
70
|
+
|
|
71
|
+
# Debug mode uses streaming so the user sees the model's output as it
|
|
72
|
+
# arrives. Non-debug mode keeps the single-blob request (works with
|
|
73
|
+
# the spinner and stays quieter for normal use).
|
|
74
|
+
payload_dict["stream"] = bool(runtime.debug_mode)
|
|
75
|
+
payload = json.dumps(payload_dict).encode()
|
|
76
|
+
|
|
77
|
+
req = urllib.request.Request(
|
|
78
|
+
f"{url}/api/chat",
|
|
79
|
+
data=payload,
|
|
80
|
+
headers={"Content-Type": "application/json"},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
if runtime.debug_mode:
|
|
85
|
+
return _query_ollama_stream(req, timeout, url)
|
|
86
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
87
|
+
data = json.loads(resp.read().decode())
|
|
88
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError) as e:
|
|
89
|
+
msg = str(e)
|
|
90
|
+
if "timed out" in msg.lower():
|
|
91
|
+
sys.exit(
|
|
92
|
+
f"Ollama request timed out after {timeout}s. For large "
|
|
93
|
+
f"diffs, raise ollama.timeout in pwnguard.yaml or switch "
|
|
94
|
+
f"to --backend claude-code (much faster on large inputs)."
|
|
95
|
+
)
|
|
96
|
+
sys.exit(
|
|
97
|
+
f"Error: cannot reach Ollama at {url}: {e}\n"
|
|
98
|
+
f"Is Ollama running (ollama serve)?"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Ollama returns {"message": {"content": ...}} on success, but error
|
|
102
|
+
# responses or unexpected shapes would otherwise raise a bare KeyError.
|
|
103
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
104
|
+
if not isinstance(message, dict) or "content" not in message:
|
|
105
|
+
err = data.get("error") if isinstance(data, dict) else None
|
|
106
|
+
# Sanitize before printing: an Ollama error blob is attacker-shaped
|
|
107
|
+
# data and would otherwise inject ANSI escapes straight into the
|
|
108
|
+
# user's terminal when sys.exit prints the message.
|
|
109
|
+
snippet = _sanitize(err or str(data)[:300]) or "(empty)"
|
|
110
|
+
sys.exit(f"Error: unexpected Ollama response: {snippet}")
|
|
111
|
+
return message["content"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _query_ollama_stream(req: urllib.request.Request, timeout: int, url: str) -> str:
|
|
115
|
+
"""Stream Ollama's chat response, echoing each token chunk to stderr.
|
|
116
|
+
|
|
117
|
+
Used in --debug mode so the user can watch the model's output in
|
|
118
|
+
real time and spot truncation, refusals, or stalls. Returns the
|
|
119
|
+
full accumulated content string after the stream is done so the
|
|
120
|
+
rest of the pipeline (parse_response, etc.) can treat it the same
|
|
121
|
+
as a non-streamed response.
|
|
122
|
+
|
|
123
|
+
Shows a "waiting for first token" spinner during prompt processing
|
|
124
|
+
so the user sees something is happening (large diffs on local 7B
|
|
125
|
+
models can sit silent for many seconds while Ollama tokenises and
|
|
126
|
+
runs prompt eval before any output token is emitted).
|
|
127
|
+
"""
|
|
128
|
+
full_content = ""
|
|
129
|
+
final_meta: dict = {}
|
|
130
|
+
waiting = ui.Spinner("Waiting for response (ollama)")
|
|
131
|
+
waiting.__enter__()
|
|
132
|
+
first_token_seen = False
|
|
133
|
+
any_chunk_seen = False
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
136
|
+
for raw_line in resp:
|
|
137
|
+
line = raw_line.strip()
|
|
138
|
+
if not line:
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
chunk = json.loads(line.decode())
|
|
142
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
143
|
+
continue
|
|
144
|
+
# First sign of life from Ollama: prompt eval is running.
|
|
145
|
+
# Update the label so the user sees activity rather than
|
|
146
|
+
# a static "waiting" message while the server processes.
|
|
147
|
+
if not any_chunk_seen:
|
|
148
|
+
any_chunk_seen = True
|
|
149
|
+
waiting.label = "Model responding (ollama)"
|
|
150
|
+
content = chunk.get("message", {}).get("content", "")
|
|
151
|
+
if content:
|
|
152
|
+
if not first_token_seen:
|
|
153
|
+
first_token_seen = True
|
|
154
|
+
waiting.__exit__(None, None, None)
|
|
155
|
+
print(ui.dim("--- begin model output ---"), file=sys.stderr)
|
|
156
|
+
full_content += content
|
|
157
|
+
sys.stderr.write(ui.dim(content))
|
|
158
|
+
sys.stderr.flush()
|
|
159
|
+
if chunk.get("done"):
|
|
160
|
+
final_meta = chunk
|
|
161
|
+
break
|
|
162
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError) as e:
|
|
163
|
+
if not first_token_seen:
|
|
164
|
+
waiting.__exit__(None, None, None)
|
|
165
|
+
msg = str(e)
|
|
166
|
+
if "timed out" in msg.lower():
|
|
167
|
+
sys.exit(
|
|
168
|
+
f"\nOllama request timed out after {timeout}s. For large "
|
|
169
|
+
f"diffs, raise ollama.timeout in pwnguard.yaml or switch "
|
|
170
|
+
f"to --backend claude-code."
|
|
171
|
+
)
|
|
172
|
+
sys.exit(f"\nError: cannot reach Ollama at {url}: {e}")
|
|
173
|
+
finally:
|
|
174
|
+
# Stream ended without ever producing a token (server closed
|
|
175
|
+
# cleanly, empty response, etc.): make sure the spinner thread
|
|
176
|
+
# is stopped so the program can exit.
|
|
177
|
+
if not first_token_seen:
|
|
178
|
+
waiting.__exit__(None, None, None)
|
|
179
|
+
|
|
180
|
+
sys.stderr.write("\n")
|
|
181
|
+
print(ui.dim("--- end model output ---"), file=sys.stderr)
|
|
182
|
+
# Surface Ollama's per-request metrics when present (handy diagnostic
|
|
183
|
+
# for why a run was slow or stopped early).
|
|
184
|
+
if final_meta:
|
|
185
|
+
eval_count = final_meta.get("eval_count")
|
|
186
|
+
eval_duration = final_meta.get("eval_duration")
|
|
187
|
+
prompt_eval = final_meta.get("prompt_eval_count")
|
|
188
|
+
done_reason = final_meta.get("done_reason")
|
|
189
|
+
bits = []
|
|
190
|
+
if prompt_eval:
|
|
191
|
+
bits.append(f"prompt: {prompt_eval} tokens")
|
|
192
|
+
if eval_count:
|
|
193
|
+
bits.append(f"output: {eval_count} tokens")
|
|
194
|
+
if eval_count and eval_duration:
|
|
195
|
+
t_per_s = eval_count / (eval_duration / 1e9)
|
|
196
|
+
bits.append(f"{t_per_s:.1f} t/s")
|
|
197
|
+
if done_reason:
|
|
198
|
+
bits.append(f"stop: {done_reason}")
|
|
199
|
+
if bits:
|
|
200
|
+
print(ui.dim("PwnGuard: " + " · ".join(bits)), file=sys.stderr)
|
|
201
|
+
return full_content
|