Coverage for src/ai_jury/adapters.py: 99%
438 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
1"""Agent adapters — each wraps one native coding-agent CLI in headless mode.
3Every adapter turns a prompt into a subprocess invocation and captures stdout as
4the agent's response. Adapters are intentionally thin: the orchestrator owns the
5prompt content and the round structure; an adapter only knows how to *invoke its
6CLI*.
8Headless invocations (verified against installed CLIs, early 2026). The prompt
9embeds the redacted diff, so it is delivered on STDIN (never argv) for every
10real adapter so it is not exposed in the process list (issue #287):
11 - Claude Code : ``claude -p --output-format text`` (prompt piped via stdin)
12 - Codex CLI : ``codex exec <args>`` (prompt piped via stdin)
13 - Antigravity : ``agy --print`` (prompt piped via stdin)
14"""
16from __future__ import annotations
18import contextlib
19import os
20import re
21import shutil
22import signal
23import subprocess
24import time
25from dataclasses import dataclass, field
27from . import privilege, redaction
28from .config import AgentSpec
30# Cap on a single local-model HTTP response body (issue #293/F-9). A chat
31# completion is small; an unbounded read from a malicious/buggy endpoint would
32# let it OOM the process.
33_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
36def _kill_process_group(proc: subprocess.Popen) -> None:
37 """Best-effort kill of the child's whole process group (issue #293/F-7)."""
38 try:
39 if hasattr(os, "killpg"): 39 ↛ 44line 39 didn't jump to line 44 because the condition on line 39 was always true
40 os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
41 return
42 except (ProcessLookupError, PermissionError, OSError):
43 pass
44 with contextlib.suppress(OSError):
45 proc.kill()
48def _spawn(argv: list[str], stdin: str | None, timeout: int) -> subprocess.CompletedProcess:
49 """Run a CLI with stdout/stderr captured, killing the whole group on timeout.
51 ``subprocess.run(timeout=…)`` SIGKILLs only the direct child, so an agent CLI
52 that wraps node/python can leak orphaned grandchildren (issue #293/F-7). The
53 child is started in its own session (process-group leader); on timeout the
54 entire group is killed before re-raising ``TimeoutExpired`` so the caller's
55 handling is unchanged. Returns a ``CompletedProcess``.
56 """
57 popen_kwargs: dict = {
58 "stdin": subprocess.PIPE if stdin is not None else None,
59 "stdout": subprocess.PIPE,
60 "stderr": subprocess.PIPE,
61 "text": True,
62 }
63 if hasattr(os, "setsid"): 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was always true
64 popen_kwargs["start_new_session"] = True
65 proc = subprocess.Popen(argv, **popen_kwargs)
66 try:
67 out, err = proc.communicate(input=stdin, timeout=timeout)
68 except subprocess.TimeoutExpired:
69 _kill_process_group(proc)
70 with contextlib.suppress(Exception):
71 proc.communicate() # reap the killed child
72 raise
73 return subprocess.CompletedProcess(argv, proc.returncode, out, err)
76def _read_only_extra_args(spec: AgentSpec) -> list[str]:
77 """The agent's ``extra_args`` with its mandatory read-only sandbox guaranteed.
79 Enforced at the adapter layer (issue #288) so a missing/misconfigured
80 ``extra_args`` cannot strip the sandbox: a reviewer of an attacker-controlled
81 diff is never write/tool-capable. Config may widen a codex sandbox knowingly,
82 but never remove the restriction.
83 """
84 return privilege.enforce_read_only(spec.vendor, spec.name, spec.extra_args)
87# Short timeout for capability/version probes. Detection is best-effort and must
88# never slow down or block a normal run, so probes are deliberately snappy.
89_VERSION_PROBE_TIMEOUT = 10
91# Matches a version-looking token, e.g. "1.2", "1.2.3", "v0.45.1".
92_VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)?")
94# Capability/version probe statuses.
95CAP_OK = "ok"
96CAP_UNKNOWN_VERSION = "unknown_version"
97CAP_UNAVAILABLE = "unavailable"
99# Stable, typed error taxonomy for failed agent executions. These codes let
100# reports and CI/policy distinguish retryable from non-retryable failures
101# instead of pattern-matching free-text error strings.
102ERR_MISSING_CLI = "missing_cli"
103ERR_AUTH_REQUIRED = "auth_required"
104ERR_PERMISSION_PROMPT = "permission_prompt"
105ERR_TIMEOUT = "timeout"
106ERR_NONZERO_EXIT = "nonzero_exit"
107ERR_EMPTY_OUTPUT = "empty_output"
108ERR_SPAWN_FAILED = "spawn_failed"
109ERR_RATE_LIMITED = "rate_limited"
110# Local/HTTP adapter could not reach its server (issue #43): connection refused,
111# DNS failure, or the local model server is not running.
112ERR_CONNECTION = "connection_error"
113# Hosted-API adapter (issue #430): the vendor's API key env var is unset. Distinct
114# from ERR_AUTH_REQUIRED (a key was sent but the server rejected it) so a report
115# can tell "never configured" apart from "misconfigured/expired/revoked".
116ERR_MISSING_API_KEY = "missing_api_key"
117# Hosted-API adapter (issue #430): the configured key contains a control
118# character and was rejected BEFORE being sent as a header, rather than
119# letting http.client raise (and risk echoing a transformed/escaped copy of
120# the secret in its exception text — see _HostedApiAdapter._invalid_key_reason).
121ERR_INVALID_API_KEY = "invalid_api_key"
122ERR_UNKNOWN = "unknown"
124ERROR_CODES = frozenset(
125 {
126 ERR_MISSING_CLI,
127 ERR_AUTH_REQUIRED,
128 ERR_PERMISSION_PROMPT,
129 ERR_TIMEOUT,
130 ERR_NONZERO_EXIT,
131 ERR_EMPTY_OUTPUT,
132 ERR_SPAWN_FAILED,
133 ERR_RATE_LIMITED,
134 ERR_CONNECTION,
135 ERR_MISSING_API_KEY,
136 ERR_INVALID_API_KEY,
137 ERR_UNKNOWN,
138 }
139)
141# Failures that are worth retrying because they are typically transient (issue
142# #30): a timeout, a rate-limit, a process that failed to spawn, or a local
143# server that was briefly unreachable (#43). Auth, missing-CLI,
144# permission-prompt, empty-output, and generic nonzero-exit are treated as
145# deterministic — retrying them just burns time and tokens.
146RETRYABLE_ERROR_CODES = frozenset(
147 {
148 ERR_TIMEOUT,
149 ERR_RATE_LIMITED,
150 ERR_SPAWN_FAILED,
151 ERR_CONNECTION,
152 }
153)
156# Ordered keyword groups for classify_stderr. Each keyword is matched on word
157# boundaries (\b...\b) so incidental substrings do NOT trigger a false
158# classification: bare "auth" matches "auth error" but not "author identity",
159# and "login" matches "login required" but not "login_attempts" ("_" is a word
160# char, so there is no boundary inside "login_attempts"). Multi-word phrases
161# tolerate a space OR "_" between tokens (e.g. "rate limit"/"rate_limit").
162def _keyword_pattern(*keywords: str) -> re.Pattern[str]:
163 parts = [r"[ _]+".join(re.escape(tok) for tok in kw.split()) for kw in keywords]
164 return re.compile(r"\b(?:" + "|".join(parts) + r")\b")
167# Order matters: auth and rate-limit signals are checked before the generic
168# permission and nonzero-exit fallbacks.
169_AUTH_RE = _keyword_pattern(
170 "not authenticated",
171 "unauthenticated",
172 "authentication",
173 "unauthorized",
174 "api key",
175 "auth",
176 "log in",
177 "login",
178 "credential",
179 "credentials",
180)
181_RATE_LIMIT_RE = _keyword_pattern("rate limit", "429", "quota", "too many requests")
182_PERMISSION_RE = _keyword_pattern(
183 "permission",
184 "permissions",
185 "approve",
186 "approval",
187 "confirm",
188 "confirmation",
189)
192def classify_stderr(returncode: int, stderr: str) -> str:
193 """Classify a nonzero-exit failure into a typed error code from its stderr.
195 Token-aware matching against the lowercased stderr: each keyword group is a
196 word-boundary regex, so incidental substrings (e.g. "author" containing
197 "auth") never cause a misclassification. Ordering matters (auth and
198 rate-limit signals are checked before the generic permission and
199 nonzero-exit fallbacks). Returns one of the ``ERR_*`` codes.
200 """
201 text = (stderr or "").lower()
202 if _AUTH_RE.search(text):
203 return ERR_AUTH_REQUIRED
204 if _RATE_LIMIT_RE.search(text):
205 return ERR_RATE_LIMITED
206 if _PERMISSION_RE.search(text):
207 return ERR_PERMISSION_PROMPT
208 del returncode
209 return ERR_NONZERO_EXIT
212@dataclass
213class AgentResult:
214 agent: str
215 vendor: str
216 ok: bool
217 output: str
218 duration_s: float
219 error: str | None = None
220 findings: list = field(default_factory=list)
221 warnings: list = field(default_factory=list)
222 error_code: str | None = None
223 # Number of attempts made for this result (issue #30): 1 means no retry.
224 # >1 records that a transient failure was retried before this outcome.
225 attempts: int = 1
228class Adapter:
229 """Base adapter. Subclasses build the argv for their CLI."""
231 # Declarative capability metadata. Real coding-agent CLIs support a headless
232 # (non-interactive) invocation and model selection; subclasses override where
233 # this differs. ``MockAdapter`` reports synthetic capabilities.
234 SUPPORTS_HEADLESS = True
235 SUPPORTS_MODEL_SELECTION = True
237 # Args passed to the CLI to print its version. Subclasses override if the CLI
238 # uses a different verb/flag (e.g. ``codex --version``).
239 _VERSION_ARGS = ("--version",)
241 def __init__(self, spec: AgentSpec):
242 self.spec = spec
244 @property
245 def name(self) -> str:
246 return self.spec.name
248 def available(self) -> bool:
249 return shutil.which(self.spec.command) is not None
251 def build_argv(self, prompt: str) -> list[str]: # pragma: no cover - overridden
252 raise NotImplementedError
254 def _stdin_for(self, prompt: str) -> str | None:
255 """Prompt to feed on stdin, or None to pass it in argv (the default)."""
256 del prompt
257 return None
259 def _version_argv(self) -> list[str]:
260 """Argv used to probe the CLI's version."""
261 return [self.spec.command, *self._VERSION_ARGS]
263 def detect_capabilities(self) -> dict:
264 """Best-effort probe of this agent's version and capabilities.
266 Returns a dict shaped like::
268 {
269 "version": "<str|None>",
270 "supports_headless": bool,
271 "supports_model_selection": bool,
272 "raw_version_output": "<short str>",
273 "status": "ok|unknown_version|unavailable",
274 "warnings": [...],
275 }
277 This is intentionally fast and forgiving: it runs ``<command> --version``
278 with a SHORT timeout and swallows ALL errors (missing CLI, timeout,
279 nonzero exit, garbage output). It NEVER raises, so it is safe to call
280 from diagnostics without blocking or crashing a run.
281 """
282 caps = {
283 "version": None,
284 "supports_headless": self.SUPPORTS_HEADLESS,
285 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
286 "raw_version_output": "",
287 "status": CAP_UNAVAILABLE,
288 "warnings": [],
289 }
291 # Not on PATH: report unavailable without spawning a subprocess.
292 if not self.available():
293 return caps
295 try:
296 # Via _spawn so the probe also runs in its own process group and the
297 # whole group is killed on timeout (issue #303/L-1) — matching the
298 # main run path; a bare subprocess.run would orphan grandchildren.
299 proc = _spawn(self._version_argv(), None, _VERSION_PROBE_TIMEOUT)
300 except subprocess.TimeoutExpired:
301 caps["status"] = CAP_UNKNOWN_VERSION
302 caps["warnings"].append(
303 f"version probe for '{self.spec.command}' timed out after {_VERSION_PROBE_TIMEOUT}s"
304 )
305 return caps
306 except Exception as exc: # noqa: BLE001 - swallow any spawn failure
307 caps["status"] = CAP_UNKNOWN_VERSION
308 caps["warnings"].append(f"version probe for '{self.spec.command}' failed: {redaction.redact(str(exc))[0]}")
309 return caps
311 raw = ((proc.stdout or "") + (proc.stderr or "")).strip()
312 caps["raw_version_output"] = redaction.redact(raw[:200])[0]
313 match = _VERSION_RE.search(raw)
314 if proc.returncode == 0 and match:
315 caps["version"] = match.group(0)
316 caps["status"] = CAP_OK
317 else:
318 caps["status"] = CAP_UNKNOWN_VERSION
319 caps["warnings"].append(
320 f"could not determine version of '{self.spec.command}' "
321 f"(exit {proc.returncode}); capabilities assumed from vendor defaults"
322 )
323 return caps
325 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
326 del phase
327 if not self.available():
328 return AgentResult(
329 self.name,
330 self.spec.vendor,
331 False,
332 "",
333 0.0,
334 f"command not found on PATH: {self.spec.command}",
335 error_code=ERR_MISSING_CLI,
336 )
337 # The effective timeout is the caller's override (the run budget, issue
338 # #30) when smaller than the agent's own bound, else the agent timeout.
339 effective_timeout = self.spec.timeout
340 if timeout is not None:
341 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
342 argv = self.build_argv(prompt)
343 stdin = self._stdin_for(prompt)
344 start = time.monotonic()
345 try:
346 proc = _spawn(argv, stdin, effective_timeout)
347 except subprocess.TimeoutExpired:
348 return AgentResult(
349 self.name,
350 self.spec.vendor,
351 False,
352 "",
353 time.monotonic() - start,
354 f"timed out after {effective_timeout}s",
355 error_code=ERR_TIMEOUT,
356 )
357 except Exception as exc: # noqa: BLE001 - surface any spawn failure
358 return AgentResult(
359 self.name,
360 self.spec.vendor,
361 False,
362 "",
363 time.monotonic() - start,
364 f"spawn failed: {redaction.redact(str(exc))[0]}",
365 error_code=ERR_SPAWN_FAILED,
366 )
367 dur = time.monotonic() - start
368 out = (proc.stdout or "").strip()
369 # A nonzero exit is ALWAYS a failure, even with stdout (issue #101): a
370 # crashing CLI can still print partial or error output, and counting that
371 # as a clean review would silently feed it into consensus, synthesis, and
372 # the CI gate. We classify from stderr (falling back to any stdout) and
373 # keep a short snippet in the error for debugging — but ok=False, so the
374 # orchestrator excludes it.
375 if proc.returncode != 0:
376 stderr = (proc.stderr or "").strip()
377 detail = stderr or out
378 # Redact before embedding in the error: a crashing CLI can dump an
379 # env var / token into its stderr, and this string is rendered into
380 # the report and posted to the PR. Mirrors the LocalAdapter path
381 # (#293/F-8); the asymmetry was a secret-leak vector (audit
382 # 2026-06-13/N-1). Classify on the raw text (no secrets in codes).
383 safe_detail = redaction.redact(detail)[0]
384 return AgentResult(
385 self.name,
386 self.spec.vendor,
387 False,
388 "",
389 dur,
390 f"exit {proc.returncode}: {safe_detail[:500]}",
391 error_code=classify_stderr(proc.returncode, stderr or out),
392 )
393 if not out:
394 # Exit 0 but nothing on stdout: the agent produced no usable review.
395 return AgentResult(
396 self.name,
397 self.spec.vendor,
398 False,
399 "",
400 dur,
401 f"exit {proc.returncode}: empty output",
402 error_code=ERR_EMPTY_OUTPUT,
403 )
404 return AgentResult(self.name, self.spec.vendor, True, out, dur)
407class ClaudeAdapter(Adapter):
408 # The prompt embeds the (redacted) diff and PR/issue context; deliver it on
409 # STDIN rather than as a process argument so it is not exposed in `ps` /
410 # /proc/<pid>/cmdline to other local users (issue #287). `claude -p` reads
411 # the prompt from stdin when no positional prompt is given.
412 def build_argv(self, prompt: str) -> list[str]:
413 del prompt
414 argv = [self.spec.command, "-p"]
415 if self.spec.model:
416 argv += ["--model", self.spec.model]
417 return argv + _read_only_extra_args(self.spec)
419 def _stdin_for(self, prompt: str) -> str | None:
420 return prompt
423class CodexAdapter(Adapter):
424 # Pipe the prompt on stdin (not positionally) so ``codex exec`` never blocks
425 # waiting for input in non-interactive runs. Sandbox flags live in extra_args;
426 # the shipped default is ``-s read-only`` (secure by default, #100) — the
427 # reviewer only reads its prompt, since the jury fetches the diff via ``gh``.
428 def build_argv(self, prompt: str) -> list[str]:
429 del prompt
430 argv = [self.spec.command, "exec"]
431 if self.spec.model:
432 argv += ["-m", self.spec.model]
433 return argv + _read_only_extra_args(self.spec)
435 def _stdin_for(self, prompt: str) -> str | None:
436 return prompt
439class AgyAdapter(Adapter):
440 # Prompt on STDIN, not argv (issue #287): `agy --print` reads the prompt from
441 # stdin when no positional prompt is given (verified against agy 1.0.6), so the
442 # redacted diff is not exposed in the process list.
443 def build_argv(self, prompt: str) -> list[str]:
444 del prompt
445 argv = [self.spec.command, "--print"]
446 if self.spec.model:
447 argv += ["--model", self.spec.model]
448 return argv + _read_only_extra_args(self.spec)
450 def _stdin_for(self, prompt: str) -> str | None:
451 return prompt
454_DEFAULT_LOCAL_ENDPOINT = "http://localhost:11434/v1"
457def _http_only_opener():
458 """An opener that handles ONLY http/https (issue #291, SSRF defense).
460 The default ``urllib`` opener honors ``file://`` and ``ftp://``, so an
461 attacker-influenced ``endpoint`` could read local files or reach other
462 schemes. This OpenerDirector registers no ``FileHandler``/``FTPHandler``, so
463 any non-http(s) URL raises ``URLError("unknown url type")`` regardless of
464 config validation — defense in depth alongside ``config._endpoint_issues``.
466 It also registers NO ``HTTPRedirectHandler`` (review of #291): otherwise a
467 malicious/compromised endpoint could 302-redirect to an internal/metadata
468 host (e.g. ``169.254.169.254``) and the opener would follow it, bypassing the
469 configured-URL validation. Without the handler a 3xx surfaces as an
470 ``HTTPError`` (a failed review) and is never followed.
471 """
472 import urllib.request
474 opener = urllib.request.OpenerDirector()
475 for handler in (
476 urllib.request.HTTPHandler,
477 urllib.request.HTTPSHandler,
478 urllib.request.HTTPDefaultErrorHandler,
479 urllib.request.HTTPErrorProcessor,
480 # UnknownHandler raises URLError("unknown url type: …") for any scheme
481 # without a registered handler — so file://, ftp://, etc. fail loudly
482 # instead of silently resolving to None.
483 urllib.request.UnknownHandler,
484 ):
485 opener.add_handler(handler())
486 return opener
489def _open(target, timeout):
490 """Open an http/https URL or Request via the restricted opener (issue #291).
492 Single seam for every local-adapter HTTP call so the SSRF-safe opener (no
493 file/ftp handlers) is always used.
494 """
495 return _http_only_opener().open(target, timeout=timeout)
498def list_local_models(endpoint: str = _DEFAULT_LOCAL_ENDPOINT) -> list[str]:
499 """List model ids from a local OpenAI-compatible server (issue #109).
501 GETs ``{endpoint}/models`` (the OpenAI-compatible listing that Ollama,
502 vLLM, LM Studio, etc. expose) and returns the model ids in their reported
503 order. Best-effort and stdlib-only: any failure (server down, bad JSON)
504 returns ``[]`` so callers can fall back gracefully.
506 The endpoint is validated here at the seam (issue #309) so EVERY caller —
507 including the un-gated ``jury init --local-endpoint`` discovery path — gets
508 the same SSRF gate that ``config._endpoint_issues`` enforces for config-file
509 endpoints: a non-``http(s)`` scheme or a non-loopback host (without the
510 ``JURY_ALLOW_REMOTE_ENDPOINT`` opt-in) yields ``[]`` without any network call.
511 """
512 import json as _json
514 from .config import _endpoint_issues
516 base = (endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/")
517 try:
518 # SSRF gate INSIDE the try (review of #309): `_endpoint_issues` calls
519 # urlsplit, which raises ValueError on a malformed URL (e.g. `http://[::1`);
520 # keep the best-effort "any failure -> []" contract rather than crashing.
521 if _endpoint_issues(base, "local-endpoint")[0]: # hard-error issues -> refuse
522 return []
523 url = base if base.endswith("/models") else f"{base}/models"
524 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310
525 data = _json.loads(resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace"))
526 except Exception: # noqa: BLE001 - discovery is best-effort
527 return []
528 models = data.get("data") if isinstance(data, dict) else None
529 if not isinstance(models, list):
530 return []
531 ids = [m.get("id") for m in models if isinstance(m, dict) and m.get("id")]
532 return [str(i) for i in ids]
535class LocalAdapter(Adapter):
536 """Open-weight / local-model reviewer over an OpenAI-compatible API (issue #43).
538 Targets the ``/v1/chat/completions`` endpoint exposed by common local servers
539 (Ollama, llama.cpp ``llama-server``, vLLM, LM Studio). It talks plain HTTP via
540 the stdlib (``urllib``) — no new dependencies and no subprocess — so one panel
541 seat can run free and fully offline, adding model diversity (the load-bearing
542 advantage) at zero marginal cost.
544 Configure as a normal ``[[agent]]`` with ``vendor = "local"``, an
545 ``endpoint`` (base URL, default ``http://localhost:11434/v1``), and a
546 ``model``. ``extra_args`` is unused. An unreachable server fails with the
547 typed ``connection_error`` code (issue #29) rather than a crash.
548 """
550 SUPPORTS_HEADLESS = True
551 SUPPORTS_MODEL_SELECTION = True
553 @property
554 def endpoint(self) -> str:
555 return (self.spec.endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/")
557 def completions_url(self) -> str:
558 """Resolve the chat-completions URL from the configured base endpoint.
560 Accepts either a base URL (``…/v1``) or a full completions URL; pure so it
561 can be unit-tested without network.
562 """
563 base = self.endpoint
564 if base.endswith("/chat/completions"):
565 return base
566 return f"{base}/chat/completions"
568 def build_payload(self, prompt: str) -> dict:
569 """Build the OpenAI-compatible chat-completions request body (pure)."""
570 return {
571 "model": self.spec.model or "",
572 "messages": [{"role": "user", "content": prompt}],
573 "stream": False,
574 "temperature": 0,
575 }
577 @staticmethod
578 def parse_content(data: dict) -> str:
579 """Extract the assistant message text from a chat-completions response."""
580 choices = data.get("choices") or []
581 if not choices:
582 return ""
583 message = choices[0].get("message") or {}
584 return (message.get("content") or "").strip()
586 @staticmethod
587 def classify_http_status(status: int) -> str:
588 """Map an HTTP error status to a typed error code (issue #29)."""
589 if status in (401, 403):
590 return ERR_AUTH_REQUIRED
591 if status == 429:
592 return ERR_RATE_LIMITED
593 return ERR_NONZERO_EXIT
595 def available(self) -> bool:
596 """A local agent is 'available' when its server answers a quick probe.
598 Probes the OpenAI-compatible ``/v1/models`` (or the endpoint root) with a
599 short timeout. Network-only; never raises.
600 """
601 import urllib.error
602 import urllib.request
604 url = f"{self.endpoint}/models"
605 try:
606 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310
607 return 200 <= resp.status < 500
608 except urllib.error.HTTPError as exc:
609 # A 4xx (e.g. 404 on /models) still means the server is up.
610 return exc.code < 500
611 except Exception: # noqa: BLE001 - unreachable server -> not available
612 return False
614 def detect_capabilities(self) -> dict:
615 reachable = self.available()
616 return {
617 "version": None,
618 "supports_headless": self.SUPPORTS_HEADLESS,
619 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
620 "raw_version_output": f"local endpoint {self.endpoint}",
621 "status": CAP_OK if reachable else CAP_UNAVAILABLE,
622 "warnings": ([] if reachable else [f"local server unreachable at {self.endpoint}"]),
623 }
625 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
626 import json as _json
627 import urllib.error
628 import urllib.request
630 del phase
631 effective_timeout = self.spec.timeout
632 if timeout is not None:
633 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
634 body = _json.dumps(self.build_payload(prompt)).encode("utf-8")
635 req = urllib.request.Request(
636 self.completions_url(),
637 data=body,
638 headers={"Content-Type": "application/json"},
639 method="POST",
640 )
641 start = time.monotonic()
642 try:
643 with _open(req, effective_timeout) as resp: # noqa: S310
644 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")
645 data = _json.loads(raw)
646 except urllib.error.HTTPError as exc:
647 detail = ""
648 try:
649 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300]
650 except Exception: # noqa: BLE001
651 detail = exc.reason or ""
652 # The body is from a possibly-untrusted endpoint and is surfaced in
653 # the report; redact recognized secrets before embedding (#293/F-8).
654 detail = redaction.redact(detail)[0]
655 return AgentResult(
656 self.name,
657 self.spec.vendor,
658 False,
659 "",
660 time.monotonic() - start,
661 f"HTTP {exc.code}: {detail}",
662 error_code=self.classify_http_status(exc.code),
663 )
664 except TimeoutError:
665 return AgentResult(
666 self.name,
667 self.spec.vendor,
668 False,
669 "",
670 time.monotonic() - start,
671 f"timed out after {effective_timeout}s",
672 error_code=ERR_TIMEOUT,
673 )
674 except urllib.error.URLError as exc:
675 return AgentResult(
676 self.name,
677 self.spec.vendor,
678 False,
679 "",
680 time.monotonic() - start,
681 f"could not reach local server at {self.endpoint}: {redaction.redact(str(exc.reason))[0]}",
682 error_code=ERR_CONNECTION,
683 )
684 except Exception as exc: # noqa: BLE001 - surface any other failure
685 return AgentResult(
686 self.name,
687 self.spec.vendor,
688 False,
689 "",
690 time.monotonic() - start,
691 f"local request failed: {redaction.redact(str(exc))[0]}",
692 error_code=ERR_UNKNOWN,
693 )
694 dur = time.monotonic() - start
695 content = self.parse_content(data)
696 if not content:
697 return AgentResult(
698 self.name,
699 self.spec.vendor,
700 False,
701 "",
702 dur,
703 "local model returned empty content",
704 error_code=ERR_EMPTY_OUTPUT,
705 )
706 return AgentResult(self.name, self.spec.vendor, True, content, dur)
709# Hosted vendor API endpoints (issue #430). Fixed, not configurable: unlike
710# `local`'s user-supplied `endpoint` (which needs the SSRF validation in
711# config._endpoint_issues), a hosted vendor's URL is a known constant, not an
712# attacker- or operator-influenceable value, so there is nothing to validate.
713_ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
714_ANTHROPIC_API_VERSION = "2023-06-01"
715_OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"
716# The Anthropic Messages API requires max_tokens on every request; there is no
717# server-side default. Generous enough for a review response, small enough to
718# bound cost/latency if a run is ever misconfigured to loop.
719_HOSTED_API_MAX_TOKENS = 4096
722def _hosted_api_status_code(status: int) -> str:
723 """Map a hosted-API HTTP status to a typed error code (issue #430).
725 Shared by every hosted-API adapter — identical mapping to
726 ``LocalAdapter.classify_http_status`` (401/403 → auth, 429 → rate limit),
727 kept as a free function since it has no per-adapter state.
728 """
729 if status in (401, 403):
730 return ERR_AUTH_REQUIRED
731 if status == 429:
732 return ERR_RATE_LIMITED
733 return ERR_NONZERO_EXIT
736def _post_json(
737 url: str, payload: dict, headers: dict[str, str], timeout: int
738) -> tuple[dict | None, str | None, str | None]:
739 """POST a JSON body and parse a JSON response (issue #430).
741 Shared HTTP mechanics for the hosted-API adapters: build the request,
742 route it through the SSRF-safe opener (``_open``, no file/ftp handlers, no
743 redirect following — the same seam ``LocalAdapter`` uses), cap the response
744 read at ``_MAX_RESPONSE_BYTES``, and classify any failure into a typed
745 error code. Returns ``(response_dict, None, None)`` on success or
746 ``(None, error_message, error_code)`` on failure — exactly one shape.
747 Response bodies are redacted before being returned in an error message
748 since they originate from the network and are surfaced in the report.
749 """
750 import json as _json
751 import urllib.error
752 import urllib.request
754 body = _json.dumps(payload).encode("utf-8")
755 req = urllib.request.Request(url, data=body, headers=headers, method="POST")
756 try:
757 with _open(req, timeout) as resp: # noqa: S310
758 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")
759 return _json.loads(raw), None, None
760 except urllib.error.HTTPError as exc:
761 detail = ""
762 try:
763 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300]
764 except Exception: # noqa: BLE001 - reading the error body is best-effort
765 detail = exc.reason or ""
766 detail = redaction.redact(detail)[0]
767 return None, f"HTTP {exc.code}: {detail}", _hosted_api_status_code(exc.code)
768 except TimeoutError:
769 return None, f"timed out after {timeout}s", ERR_TIMEOUT
770 except urllib.error.URLError as exc:
771 return (
772 None,
773 f"could not reach {url}: {redaction.redact(str(exc.reason))[0]}",
774 ERR_CONNECTION,
775 )
776 except Exception as exc: # noqa: BLE001 - surface any other failure
777 return None, f"request failed: {redaction.redact(str(exc))[0]}", ERR_UNKNOWN
780class _HostedApiAdapter(Adapter):
781 """Shared base for hosted-vendor-API reviewers keyed by an env-var API key.
783 No CLI install, no interactive login, no subprocess: just an HTTP call
784 over stdlib ``urllib`` to the vendor's real hosted API (issue #430), the
785 same no-subprocess/no-new-dependency design as ``LocalAdapter`` but
786 pointed at a hosted endpoint instead of a local server. The API key is
787 read from the environment ONLY, never from ``jury.toml``, so it cannot
788 leak into a checked-in config; the endpoint is a fixed per-vendor
789 constant, not a config value, so there is no SSRF surface to guard the
790 way `local`'s `endpoint` needs.
791 """
793 SUPPORTS_HEADLESS = True
794 SUPPORTS_MODEL_SELECTION = True
796 # Subclasses override.
797 _API_KEY_ENV: str = ""
799 def _api_key(self) -> str:
800 return os.environ.get(self._API_KEY_ENV, "")
802 def _api_url(self) -> str: # pragma: no cover - overridden
803 raise NotImplementedError
805 def _invalid_key_reason(self) -> str | None:
806 """None if the key is safe to use as an HTTP header value; else why not.
808 A key containing a control character (most plausibly a stray
809 trailing ``\\n`` from a file/k8s-secret/`.env` mount) trips CPython's
810 ``http.client`` header-injection guard. That guard reports the
811 rejected value via ``repr()`` (e.g. an embedded newline becomes the
812 two literal characters ``\\`` ``n``), which does **not** byte-for-byte
813 match the raw key — so a literal substring scrub of the exception
814 text (see :meth:`_scrub_secret`) cannot reliably catch it; the
815 transformed text is no longer equal to the original secret. Validate
816 and reject *before* the key ever reaches a header instead of trying
817 to scrub it back out afterward.
818 """
819 if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in self._api_key()):
820 return (
821 f"{self._API_KEY_ENV} contains a control character (e.g. a stray "
822 f"trailing newline from how the secret was loaded) and cannot be "
823 f"used as an HTTP header value"
824 )
825 return None
827 def _scrub_secret(self, text: str) -> str:
828 """Strip the literal API key value from an error message (issue #430).
830 Defense-in-depth alongside ``redaction.redact()`` (which only
831 recognizes known vendor-token *shapes* via regex) for any leak path
832 NOT already ruled out by :meth:`_invalid_key_reason` — e.g. a
833 well-formed key that still ends up quoted in some other library's
834 error text. Not a substitute for that check: once a value contains
835 control characters, downstream formatting (``repr()``, percent-
836 encoding, ...) can transform it before it reaches an error message,
837 and a literal match against the *original* key would then silently
838 miss it — which is exactly why control characters are rejected
839 upfront in :meth:`run` instead of relying on this alone.
840 """
841 key = self._api_key()
842 if key and key in text:
843 return text.replace(key, "[REDACTED]")
844 return text
846 def available(self) -> bool:
847 """Available when a *usable* API key is set — a fast, network-free check.
849 Unlike ``LocalAdapter.available()`` (which probes the server, since a
850 local endpoint's reachability is genuinely uncertain), a hosted
851 vendor's API is assumed reachable; the two real unknowns locally are
852 whether the operator configured a key at all, and whether it's
853 actually usable as a header value (see :meth:`_invalid_key_reason`) —
854 a key that will be rejected by :meth:`run` should not report as
855 available here either, or a capability check (``jury --doctor``)
856 would give a falsely reassuring answer.
857 """
858 return bool(self._api_key()) and self._invalid_key_reason() is None
860 def detect_capabilities(self) -> dict:
861 key_set = bool(self._api_key())
862 invalid_reason = self._invalid_key_reason() if key_set else None
863 has_key = key_set and invalid_reason is None
864 if not key_set:
865 warnings = [f"{self._API_KEY_ENV} is not set in the environment"]
866 elif invalid_reason:
867 warnings = [invalid_reason]
868 else:
869 warnings = []
870 return {
871 "version": None,
872 "supports_headless": self.SUPPORTS_HEADLESS,
873 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
874 "raw_version_output": f"hosted API {self._api_url()}",
875 "status": CAP_OK if has_key else CAP_UNAVAILABLE,
876 "warnings": warnings,
877 }
879 def build_payload(self, prompt: str) -> dict: # pragma: no cover - overridden
880 raise NotImplementedError
882 def _headers(self) -> dict[str, str]: # pragma: no cover - overridden
883 raise NotImplementedError
885 @staticmethod
886 def parse_content(data: dict) -> str: # pragma: no cover - overridden
887 raise NotImplementedError
889 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
890 del phase
891 # Checked independently of available() (not just "not available()"):
892 # available() now also returns False for a key that IS set but
893 # invalid, and that case needs its own distinct error_code/message
894 # below rather than the misleading "is not set" one.
895 if not self._api_key():
896 return AgentResult(
897 self.name,
898 self.spec.vendor,
899 False,
900 "",
901 0.0,
902 f"{self._API_KEY_ENV} is not set in the environment",
903 error_code=ERR_MISSING_API_KEY,
904 )
905 invalid_reason = self._invalid_key_reason()
906 if invalid_reason is not None:
907 # Reject before the key ever reaches a header — see
908 # _invalid_key_reason for why post-hoc scrubbing can't be trusted
909 # here. This message never echoes the key itself.
910 return AgentResult(
911 self.name, self.spec.vendor, False, "", 0.0,
912 invalid_reason, error_code=ERR_INVALID_API_KEY,
913 )
914 effective_timeout = self.spec.timeout
915 if timeout is not None:
916 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
917 start = time.monotonic()
918 data, err_msg, err_code = _post_json(
919 self._api_url(), self.build_payload(prompt), self._headers(), effective_timeout
920 )
921 dur = time.monotonic() - start
922 if err_msg is not None:
923 return AgentResult(
924 self.name, self.spec.vendor, False, "", dur,
925 self._scrub_secret(err_msg), error_code=err_code,
926 )
927 content = self.parse_content(data or {})
928 if not content:
929 return AgentResult(
930 self.name,
931 self.spec.vendor,
932 False,
933 "",
934 dur,
935 "hosted API returned empty content",
936 error_code=ERR_EMPTY_OUTPUT,
937 )
938 return AgentResult(self.name, self.spec.vendor, True, content, dur)
941class AnthropicApiAdapter(_HostedApiAdapter):
942 """Hosted Anthropic Messages API reviewer, keyed by ``ANTHROPIC_API_KEY`` (issue #430).
944 Configure as a normal ``[[agent]]`` with ``vendor = "anthropic-api"`` and a
945 ``model`` (e.g. a current Claude model id) — no ``command``, no ``claude``
946 CLI install or interactive login needed.
947 """
949 _API_KEY_ENV = "ANTHROPIC_API_KEY"
951 def _api_url(self) -> str:
952 return _ANTHROPIC_API_URL
954 def build_payload(self, prompt: str) -> dict:
955 """Build the Anthropic Messages API request body (pure)."""
956 return {
957 "model": self.spec.model or "",
958 "max_tokens": _HOSTED_API_MAX_TOKENS,
959 "messages": [{"role": "user", "content": prompt}],
960 }
962 def _headers(self) -> dict[str, str]:
963 return {
964 "Content-Type": "application/json",
965 "x-api-key": self._api_key(),
966 "anthropic-version": _ANTHROPIC_API_VERSION,
967 }
969 @staticmethod
970 def parse_content(data: dict) -> str:
971 """Extract the assistant text from a Messages API response."""
972 if not isinstance(data, dict):
973 return ""
974 blocks = data.get("content") or []
975 texts = [
976 block.get("text", "")
977 for block in blocks
978 if isinstance(block, dict) and block.get("type") == "text"
979 ]
980 return "".join(texts).strip()
983class OpenAiApiAdapter(_HostedApiAdapter):
984 """Hosted OpenAI Chat Completions API reviewer, keyed by ``OPENAI_API_KEY`` (issue #430).
986 Configure as a normal ``[[agent]]`` with ``vendor = "openai-api"`` and a
987 ``model`` (e.g. a current GPT model id) — no ``command``, no ``codex`` CLI
988 install or interactive login needed. Same request/response shape as
989 ``LocalAdapter`` (both are OpenAI-compatible chat completions), just
990 against the real hosted API with an ``Authorization`` header.
991 """
993 _API_KEY_ENV = "OPENAI_API_KEY"
995 def _api_url(self) -> str:
996 return _OPENAI_API_URL
998 def build_payload(self, prompt: str) -> dict:
999 """Build the OpenAI chat-completions request body (pure)."""
1000 return {
1001 "model": self.spec.model or "",
1002 "messages": [{"role": "user", "content": prompt}],
1003 }
1005 def _headers(self) -> dict[str, str]:
1006 return {
1007 "Content-Type": "application/json",
1008 "Authorization": f"Bearer {self._api_key()}",
1009 }
1011 @staticmethod
1012 def parse_content(data: dict) -> str:
1013 """Extract the assistant message text from a chat-completions response."""
1014 if not isinstance(data, dict):
1015 return ""
1016 choices = data.get("choices") or []
1017 if not choices:
1018 return ""
1019 message = choices[0].get("message") or {}
1020 return (message.get("content") or "").strip()
1023_GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
1026class GoogleApiAdapter(_HostedApiAdapter):
1027 """Hosted Google Gemini API reviewer, keyed by ``GEMINI_API_KEY`` (issue #432).
1029 Configure as a normal ``[[agent]]`` with ``vendor = "google-api"`` and a
1030 ``model`` (e.g. a current Gemini model id) — no ``command``, no ``agy``
1031 CLI install or interactive login needed.
1033 Two differences from the other two hosted adapters:
1035 - The Gemini API embeds the model id in the URL **path**
1036 (``.../models/{model}:generateContent``), not the request body, so
1037 ``_api_url()`` is built from ``self.spec.model`` on every call rather
1038 than returning a fixed constant like the other two adapters.
1039 - The key is sent via the ``x-goog-api-key`` header. Gemini also accepts
1040 the key as a ``?key=...`` query parameter, but a query-string key is a
1041 much easier accidental-leak vector (proxy/access logs, anything that
1042 prints the request URL) than a header — deliberately not supported.
1044 A prompt blocked by Gemini's safety filters comes back with an empty
1045 ``candidates`` list (and a ``promptFeedback.blockReason``); this is not
1046 distinguished from a genuinely empty response and both currently surface
1047 as the same generic ``ERR_EMPTY_OUTPUT`` — a possible future refinement,
1048 not required for parity with the other two adapters.
1049 """
1051 _API_KEY_ENV = "GEMINI_API_KEY"
1053 def _api_url(self) -> str:
1054 # Escape the model id as a single path segment (issue #432 review): an
1055 # operator-configured model containing reserved URL characters
1056 # (`/`, `?`, `#`, ...) would otherwise change the request's path/query
1057 # semantics instead of staying a single `{model}` segment.
1058 import urllib.parse
1060 model = urllib.parse.quote(self.spec.model or "", safe="")
1061 return f"{_GEMINI_API_BASE}/{model}:generateContent"
1063 def build_payload(self, prompt: str) -> dict:
1064 """Build the Gemini ``generateContent`` request body (pure)."""
1065 return {"contents": [{"parts": [{"text": prompt}]}]}
1067 def _headers(self) -> dict[str, str]:
1068 return {
1069 "Content-Type": "application/json",
1070 "x-goog-api-key": self._api_key(),
1071 }
1073 @staticmethod
1074 def parse_content(data: dict) -> str:
1075 """Extract the assistant text from a ``generateContent`` response."""
1076 if not isinstance(data, dict):
1077 return ""
1078 candidates = data.get("candidates") or []
1079 if not candidates or not isinstance(candidates[0], dict):
1080 return ""
1081 content = candidates[0].get("content")
1082 if not isinstance(content, dict):
1083 return ""
1084 parts = content.get("parts") or []
1085 texts = [
1086 part.get("text", "")
1087 for part in parts
1088 if isinstance(part, dict) and isinstance(part.get("text", ""), str)
1089 ]
1090 return "".join(texts).strip()
1093class MockAdapter(Adapter):
1094 """Offline adapter for tests and ``--mock`` runs.
1096 Produces deterministic, phase-aware text so the full orchestration pipeline
1097 can run end-to-end without live CLIs, auth, or token spend.
1098 """
1100 # Synthetic capabilities: the mock is offline and runs no real CLI.
1101 SUPPORTS_HEADLESS = True
1102 SUPPORTS_MODEL_SELECTION = False
1104 def available(self) -> bool:
1105 return True
1107 def detect_capabilities(self) -> dict:
1108 """Deterministic fake capabilities so doctor/tests stay stable offline."""
1109 return {
1110 "version": "mock-1.0",
1111 "supports_headless": self.SUPPORTS_HEADLESS,
1112 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
1113 "raw_version_output": "mock-1.0",
1114 "status": CAP_OK,
1115 "warnings": [],
1116 }
1118 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
1119 del prompt, timeout
1120 n = self.name
1121 if phase == "review":
1122 body = (
1123 f"- **[major]** `src/example.py:42` — {n}: unchecked return value "
1124 f"may swallow an error.\n"
1125 f"- **[minor]** `src/example.py:7` — {n}: missing docstring.\n\n"
1126 "```json\n"
1127 "[\n"
1128 ' {"severity": "major", "file": "src/example.py", "line": 42, '
1129 f'"claim": "{n}: unchecked return value may swallow an error", '
1130 '"evidence": "the added code ignores the return value of int(x)", '
1131 '"suggested_fix": "check the result and raise on failure", '
1132 f'"confidence": "high", "reviewer": "{n}"}},\n'
1133 ' {"severity": "minor", "file": "src/example.py", "line": 7, '
1134 f'"claim": "{n}: missing docstring", '
1135 '"evidence": "the new function parse() has no docstring", '
1136 '"suggested_fix": "add a one-line docstring", '
1137 f'"confidence": "medium", "reviewer": "{n}"}}\n'
1138 "]\n"
1139 "```"
1140 )
1141 elif phase == "debate":
1142 body = (
1143 f"## AGREE\n- {n}: confirm the unchecked-return finding at "
1144 f"`src/example.py:42`.\n"
1145 f"## DISPUTE\n- {n}: the missing-docstring finding is a nit, not blocking.\n"
1146 f"## MISSED\n- {n}: no test covers the error branch."
1147 )
1148 elif phase == "verify":
1149 body = (
1150 "Verification: confirming the unchecked-return finding at "
1151 "`src/example.py:42`; the missing-docstring claim at `:7` is a nit "
1152 "not supported as blocking.\n\n"
1153 "```json\n"
1154 "[\n"
1155 ' {"file": "src/example.py", "line": 42, '
1156 '"claim": "unchecked return value may swallow an error", '
1157 '"status": "verified", '
1158 '"reasoning": "the added code ignores the return value of int(x)"},\n'
1159 ' {"file": "src/example.py", "line": 7, '
1160 '"claim": "missing docstring", '
1161 '"status": "unsupported", '
1162 '"reasoning": "a missing docstring is not a defect the diff introduces"}\n'
1163 "]\n"
1164 "```"
1165 )
1166 else: # synthesis
1167 body = (
1168 "## Verdict\nREQUEST CHANGES — one confirmed major issue.\n\n"
1169 "## Consensus findings\n- **[major]** `src/example.py:42` — unchecked "
1170 "return value (raised by all reviewers).\n\n"
1171 "## Disputed findings\n- Missing docstring: ruled non-blocking.\n\n"
1172 "## Notable single-reviewer findings\n- Missing test for the error branch."
1173 )
1174 return AgentResult(n, self.spec.vendor, True, body, 0.0)
1177_VENDOR_ADAPTERS: dict[str, type[Adapter]] = {
1178 "anthropic": ClaudeAdapter,
1179 "openai": CodexAdapter,
1180 "google": AgyAdapter,
1181 "local": LocalAdapter,
1182 "anthropic-api": AnthropicApiAdapter,
1183 "openai-api": OpenAiApiAdapter,
1184 "google-api": GoogleApiAdapter,
1185}
1188def make_adapter(spec: AgentSpec, mock: bool = False) -> Adapter:
1189 if mock:
1190 return MockAdapter(spec)
1191 cls = _VENDOR_ADAPTERS.get(spec.vendor)
1192 if cls is None:
1193 # Unknown vendor: treat command as a print-style CLI (prompt as last arg).
1194 return AgyAdapter(spec)
1195 return cls(spec)