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 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
@@ -0,0 +1,4 @@
1
+ from pwnguard.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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