Coverage for src/ai_jury/config.py: 99%
288 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
1"""Configuration loading for the jury.
3Config is TOML (see ``jury.toml``). The loader is tolerant: a missing config
4file falls back to a sensible built-in default so the tool runs out of the box.
5"""
7from __future__ import annotations
9import os
10import tomllib
11from dataclasses import dataclass, field
12from pathlib import Path
13from urllib.parse import urlsplit
15from .redaction import redact
17# Hosts that are safe to reach over plaintext http and never an SSRF target.
18_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1", "[::1]")
20# Upper bound on a config/policy TOML file (issue #316/L-5). A real config is a
21# few KB; refuse a multi-MB / pathological file so `tomllib` can't be driven to
22# exhaust memory (the file may be attacker-supplied when jury runs from a PR
23# checkout). Mirrors the cache's _MAX_CACHE_BYTES.
24_MAX_CONFIG_BYTES = 4 * 1024 * 1024
27def _read_toml_bounded(path: Path) -> dict:
28 """Parse a TOML file with a size cap (issue #316/L-5)."""
29 with path.open("rb") as fh:
30 raw = fh.read(_MAX_CONFIG_BYTES + 1)
31 if len(raw) > _MAX_CONFIG_BYTES:
32 raise ConfigError(f"config file '{path}' exceeds the {_MAX_CONFIG_BYTES}-byte limit.")
33 try:
34 text = raw.decode("utf-8")
35 except UnicodeDecodeError:
36 # TOML is UTF-8 by spec; surface a clean error instead of a raw
37 # UnicodeDecodeError (review of #316 — the prior tomllib.load crashed the
38 # same way on bad bytes; now it's a ConfigError).
39 raise ConfigError(f"config file '{path}' is not valid UTF-8.") from None
40 try:
41 return tomllib.loads(text)
42 except tomllib.TOMLDecodeError as exc:
43 raise ConfigError(f"invalid TOML in config file '{path}': {redact(str(exc))[0]}") from None
46def _is_relative_path_command(command: str) -> bool:
47 """True for a relative command that contains a path separator (#293/F-6).
49 A bare name (``codex``) is fine — it is resolved on PATH. An absolute path
50 (``/usr/bin/codex``) is fine — it is explicit. A relative path with a
51 separator (``./tools/codex``, ``bin/agy``) is rejected because it resolves a
52 binary from an attacker-influenceable working-directory-relative location.
53 """
54 has_sep = "/" in command or "\\" in command or (os.altsep is not None and os.altsep in command)
55 return has_sep and not Path(command).is_absolute()
58# Env opt-in for a non-loopback local endpoint. It lives in the environment, NOT
59# in jury.toml, on purpose (review of #291): the threat model is an
60# attacker-controlled config, so the opt-in must sit OUTSIDE the surface the
61# attacker controls. Without it, a non-loopback host (incl. cloud-metadata
62# 169.254.169.254) is a hard error so an attacker config cannot drive an
63# SSRF POST to an internal address — matching the default-secure F-1 posture.
64_ALLOW_REMOTE_ENDPOINT_ENV = "JURY_ALLOW_REMOTE_ENDPOINT"
66# Opt-in strict mode (issue #296): when set, every agent ``command`` must be an
67# absolute path — rejecting even a bare name, whose PATH resolution an attacker
68# who controls the CI runner's PATH could hijack with a shim. Off by default so
69# the convenient bare-name (``claude``) keeps working for local use.
70_REQUIRE_ABSOLUTE_COMMAND_ENV = "JURY_REQUIRE_ABSOLUTE_COMMAND"
73def _endpoint_issues(endpoint: str, label: str) -> tuple[list[str], list[str]]:
74 """Validate a local-agent ``endpoint`` URL (issue #291, SSRF defense).
76 Returns ``(errors, warnings)``. A non-``http``/``https`` scheme is a hard
77 error (blocks ``file://``/``ftp://`` and other SSRF primitives). A non-loopback
78 host is also a hard error UNLESS the operator opts in via the
79 ``JURY_ALLOW_REMOTE_ENDPOINT`` environment variable (a remote model server is
80 a legitimate but riskier choice the attacker-controlled config must not be
81 able to select on its own); when opted in it degrades to a warning, plus a
82 cleartext warning for plaintext ``http``.
83 """
84 errors: list[str] = []
85 warnings: list[str] = []
86 # `urlsplit` raises ValueError on a malformed URL (e.g. `http://[::1`,
87 # "Invalid IPv6 URL"). Convert that to a hard config error (issue #315) so
88 # `validate_config` reports it cleanly instead of crashing with a stack trace
89 # — the malformed string is, by definition, not a usable endpoint.
90 try:
91 parsed = urlsplit(endpoint)
92 parsed.hostname # noqa: B018 - also raises ValueError on a bad IPv6 host
93 except ValueError:
94 errors.append(f"agent '{label}' endpoint '{endpoint}' is not a valid URL.")
95 return errors, warnings
96 scheme = (parsed.scheme or "").lower()
97 if scheme not in ("http", "https"):
98 errors.append(
99 f"agent '{label}' endpoint scheme '{parsed.scheme or '(none)'}' is "
100 f"not allowed; use http or https."
101 )
102 return errors, warnings
103 host = (parsed.hostname or "").lower()
104 if host in _LOOPBACK_HOSTS:
105 return errors, warnings
106 if not os.environ.get(_ALLOW_REMOTE_ENDPOINT_ENV):
107 errors.append(
108 f"agent '{label}' endpoint host '{host or '(none)'}' is not loopback; "
109 f"a non-loopback model server (incl. internal/metadata addresses) is "
110 f"refused by default. Set {_ALLOW_REMOTE_ENDPOINT_ENV}=1 in the "
111 f"environment to allow a trusted remote endpoint."
112 )
113 return errors, warnings
114 warnings.append(
115 f"agent '{label}' endpoint host '{host or '(none)'}' is not loopback; "
116 f"the (redacted) diff is sent to a remote server — ensure it is trusted "
117 f"and not an internal/metadata address."
118 )
119 if scheme == "http": 119 ↛ 124line 119 didn't jump to line 124 because the condition on line 119 was always true
120 warnings.append(
121 f"agent '{label}' endpoint uses plaintext http to a non-loopback "
122 f"host; prefer https so the prompt is not sent in cleartext."
123 )
124 return errors, warnings
127DEFAULT_CONFIG: dict = {
128 "jury": {
129 "rounds": 2,
130 "chair": "claude",
131 "timeout": 600,
132 "parallel": True,
133 "verify": True,
134 "ci": {"fail_on": ["critical", "major"], "ignore_unverified": True},
135 "context": {"mode": "diff-only", "redact_secrets": True},
136 },
137 # Execution controls (issue #30) are optional and conservative by default:
138 # no overall/per-phase budget and zero retries, so out-of-the-box behaviour
139 # is unchanged. They live under [jury] and are documented in
140 # docs/configuration.md.
141 "agent": [
142 {
143 "name": "claude",
144 "vendor": "anthropic",
145 "command": "claude",
146 "extra_args": [
147 "--output-format",
148 "text",
149 "--disallowed-tools",
150 "Edit,Write,NotebookEdit,Bash",
151 # Avoid `-p` blocking on a permission prompt in non-interactive mode.
152 "--dangerously-skip-permissions",
153 ],
154 },
155 {
156 "name": "codex",
157 "vendor": "openai",
158 "command": "codex",
159 # `codex exec` reads the prompt from stdin (see CodexAdapter) and only
160 # needs to READ it and print a review — the diff is fetched by the
161 # jury process (`gh`), not the agent. So the secure default is a
162 # read-only sandbox (issue #100); widen it (e.g. `-s workspace-write`
163 # or `danger-full-access`) only if your workflow truly needs it.
164 "extra_args": ["-s", "read-only"],
165 },
166 {
167 "name": "agy",
168 "vendor": "google",
169 "command": "agy",
170 # `--dangerously-skip-permissions` avoids a non-interactive permission
171 # prompt hanging the run; `--sandbox` keeps the agent's tools
172 # restricted while it reviews untrusted content (issue #100).
173 "extra_args": ["--dangerously-skip-permissions", "--sandbox"],
174 },
175 ],
176}
179# Vendors that talk HTTP directly (no CLI subprocess), so they need no
180# `command`: `local` (a user-supplied OpenAI-compatible server, issue #43) and
181# the hosted-API adapters (a real vendor API keyed by an env-var API key,
182# issue #430/#432).
183_NO_COMMAND_VENDORS = ("local", "anthropic-api", "openai-api", "google-api", "openai-compatible")
185KNOWN_VENDORS = (
186 "anthropic", "openai", "google", "local",
187 "anthropic-api", "openai-api", "google-api",
188 "openai-compatible", "cli",
189)
191KNOWN_TOP_LEVEL_KEYS = ("jury", "agent")
192KNOWN_JURY_KEYS = (
193 "rounds",
194 "chair",
195 "timeout",
196 "parallel",
197 "verify",
198 "ci",
199 "context",
200 "seed",
201 "anonymize_debate",
202 "prefer_non_reviewer_chair",
203 # Demote a local-only finding to non-blocking severity (issue #442).
204 "demote_local_only",
205 # Execution controls (issue #30).
206 "total_timeout",
207 "phase_timeout",
208 "retries",
209 # Adaptive rounds (issue #40).
210 "max_rounds",
211 "early_stop",
212 # Risk-aware auto-depth (issue #120).
213 "auto_depth",
214 # Full-transcript / verbose rendering (rendering-only; not in config_hash).
215 "transcript",
216 # Final-verdict mode: "chair" synthesis or panel "vote" (rendering-only).
217 "decision",
218 # Animated theater view defaults (rendering-only; issue #364).
219 "theater",
220 "theater_style",
221 # Large-diff handling (issue #31).
222 "diff",
223)
224KNOWN_AGENT_KEYS = (
225 "name",
226 "vendor",
227 "command",
228 "model",
229 "timeout",
230 "enabled",
231 "extra_args",
232 # OpenAI-compatible local/open-weight endpoint (issue #43).
233 "endpoint",
234 # Universal agent extensions
235 "api_key_env",
236 "prompt_mode",
237 "headers",
238)
241class ConfigError(Exception):
242 """Raised when a jury configuration is invalid."""
245def validate_config(data: dict, strict: bool = False) -> list:
246 """Validate a raw config dict.
248 Raises ``ConfigError`` with an actionable message on hard-invalid input
249 (rounds < 1, timeout <= 0, duplicate agent names, empty/missing command,
250 no agents at all). Returns a list of warning strings for soft issues
251 (unknown vendor, chair not an enabled agent, unknown keys).
253 When ``strict`` is True, soft issues raise ``ConfigError`` instead of
254 being returned as warnings.
255 """
256 warnings: list = []
257 errors: list = []
259 if not isinstance(data, dict):
260 raise ConfigError("config root must be a table/dict.")
262 # Unknown top-level keys (soft).
263 for key in data:
264 if key not in KNOWN_TOP_LEVEL_KEYS:
265 warnings.append(
266 f"unknown top-level key '{key}' (expected one of "
267 f"{', '.join(KNOWN_TOP_LEVEL_KEYS)})."
268 )
270 jury = data.get("jury", {})
271 if not isinstance(jury, dict):
272 raise ConfigError("[jury] must be a table.")
274 for key in jury:
275 if key not in KNOWN_JURY_KEYS:
276 warnings.append(
277 f"unknown key 'jury.{key}' (expected one of {', '.join(KNOWN_JURY_KEYS)})."
278 )
280 # rounds >= 1 (hard).
281 rounds = jury.get("rounds", 1)
282 if not isinstance(rounds, int) or isinstance(rounds, bool) or rounds < 1:
283 errors.append(f"jury.rounds must be an integer >= 1 (got {rounds!r}).")
285 # timeout > 0 (hard).
286 timeout = jury.get("timeout", 600)
287 if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
288 errors.append(f"jury.timeout must be a positive integer (got {timeout!r}).")
290 # Execution controls (issue #30): optional positive budgets, non-negative
291 # retries (hard when present and invalid).
292 for key in ("total_timeout", "phase_timeout"):
293 val = jury.get(key)
294 if val is not None and (not isinstance(val, int) or isinstance(val, bool) or val <= 0):
295 errors.append(f"jury.{key} must be a positive integer when set (got {val!r}).")
296 retries = jury.get("retries", 0)
297 if not isinstance(retries, int) or isinstance(retries, bool) or retries < 0:
298 errors.append(f"jury.retries must be an integer >= 0 (got {retries!r}).")
300 # Final-verdict mode (issue #220): "chair" or "vote".
301 decision = jury.get("decision")
302 if decision is not None and str(decision).strip().lower() not in ("chair", "vote"):
303 errors.append(f"jury.decision must be 'chair' or 'vote' (got {decision!r}).")
305 # Animated theater defaults (issue #364): theater is a bool, style is enum.
306 theater = jury.get("theater")
307 if theater is not None and not isinstance(theater, bool):
308 errors.append(f"jury.theater must be true or false (got {theater!r}).")
309 style = jury.get("theater_style")
310 if style is not None and str(style).strip().lower() not in ("flat", "pixel"):
311 errors.append(f"jury.theater_style must be 'flat' or 'pixel' (got {style!r}).")
313 # Adaptive rounds (issue #40): max_rounds >= 1 (hard); early_stop is a bool.
314 max_rounds = jury.get("max_rounds")
315 if max_rounds is not None and (
316 not isinstance(max_rounds, int) or isinstance(max_rounds, bool) or max_rounds < 1
317 ):
318 errors.append(f"jury.max_rounds must be an integer >= 1 when set (got {max_rounds!r}).")
320 # Large-diff handling (issue #31): [jury.diff] sizes are positive ints.
321 diff_cfg = jury.get("diff", {})
322 if not isinstance(diff_cfg, dict):
323 errors.append("[jury.diff] must be a table.")
324 else:
325 for key in ("max_bytes", "chunk_max_bytes"):
326 val = diff_cfg.get(key)
327 if val is not None and (not isinstance(val, int) or isinstance(val, bool) or val <= 0):
328 errors.append(f"jury.diff.{key} must be a positive integer when set (got {val!r}).")
330 agents_data = data.get("agent", [])
331 if not isinstance(agents_data, list):
332 raise ConfigError("[[agent]] must be an array of tables.")
334 # At least one agent (hard).
335 if not agents_data:
336 errors.append("no agents configured; define at least one [[agent]] entry.")
338 seen_names: set = set()
339 enabled_names: set = set()
340 for idx, agent in enumerate(agents_data):
341 if not isinstance(agent, dict):
342 errors.append(f"agent[{idx}] must be a table.")
343 continue
345 for key in agent:
346 if key not in KNOWN_AGENT_KEYS:
347 warnings.append(
348 f"unknown key 'agent[{idx}].{key}' (expected one of "
349 f"{', '.join(KNOWN_AGENT_KEYS)})."
350 )
352 name = agent.get("name", "")
353 label = name or f"agent[{idx}]"
355 # Unique, non-empty name (hard for duplicates).
356 if not name:
357 errors.append(f"agent[{idx}] is missing a non-empty 'name'.")
358 elif name in seen_names:
359 errors.append(f"duplicate agent name '{name}'.")
360 else:
361 seen_names.add(name)
363 # A local OpenAI-compatible agent (issue #43) talks to an HTTP
364 # ``endpoint`` (default ``http://localhost:11434/v1``) instead of a CLI,
365 # so it does not require a ``command``; it does need a ``model``. A
366 # hosted-API agent (issue #430) likewise talks HTTP instead of a CLI —
367 # to the vendor's fixed, non-configurable endpoint — so it also needs
368 # no ``command``, but does need a ``model``; it has no ``endpoint`` to
369 # validate since the URL isn't a config value. Every other vendor
370 # requires a non-empty ``command``.
371 command = agent.get("command", "")
372 vendor_value = agent.get("vendor", "")
373 has_endpoint = bool(agent.get("endpoint"))
374 is_local_or_http = vendor_value in _NO_COMMAND_VENDORS or vendor_value.endswith("-api") or has_endpoint
375 if is_local_or_http:
376 if not agent.get("model"):
377 warnings.append(
378 f"agent '{label}' (vendor '{vendor_value}') has no 'model'; the "
379 f"server or API call will likely reject the request."
380 )
381 endpoint = agent.get("endpoint")
382 if endpoint:
383 e_errors, e_warnings = _endpoint_issues(endpoint, label)
384 errors.extend(e_errors)
385 warnings.extend(e_warnings)
386 elif not command:
387 errors.append(f"agent '{label}' is missing a non-empty 'command'.")
388 elif _is_relative_path_command(command):
389 # A relative path with separators (e.g. ./tools/codex, bin/agy) could
390 # resolve a binary from an attacker-influenced location (#293/F-6).
391 # Require a bare name (resolved on PATH) or an absolute path.
392 errors.append(
393 f"agent '{label}' command '{command}' is a relative path; use a "
394 f"bare name (resolved on PATH) or an absolute path."
395 )
396 elif os.environ.get(_REQUIRE_ABSOLUTE_COMMAND_ENV) and not Path(command).is_absolute():
397 # Strict opt-in (issue #296): in a hardened/CI context, refuse even a
398 # bare name so a poisoned PATH can't resolve a shim — require an
399 # absolute path for every agent command.
400 errors.append(
401 f"agent '{label}' command '{command}' is not an absolute path; "
402 f"{_REQUIRE_ABSOLUTE_COMMAND_ENV} requires every agent command to "
403 f"be an absolute path."
404 )
406 # Per-agent timeout (hard if present and invalid).
407 a_timeout = agent.get("timeout", 600)
408 if not isinstance(a_timeout, int) or isinstance(a_timeout, bool) or a_timeout <= 0:
409 errors.append(
410 f"agent '{label}' timeout must be a positive integer (got {a_timeout!r})."
411 )
413 # Known vendor (soft).
414 vendor = agent.get("vendor", "")
415 if vendor not in KNOWN_VENDORS:
416 warnings.append(
417 f"agent '{label}' has unknown vendor '{vendor}' (expected one "
418 f"of {', '.join(KNOWN_VENDORS)}); using generic fallback."
419 )
421 if name and agent.get("enabled", True):
422 enabled_names.add(name)
424 # Chair must reference an enabled agent (soft). The literal "rotate" is a
425 # valid special value (deterministic per-run rotation) and never warns.
426 chair = jury.get("chair", "claude")
427 if enabled_names and chair != "rotate" and chair not in enabled_names:
428 warnings.append(
429 f"jury.chair '{chair}' is not an enabled agent (enabled: "
430 f"{', '.join(sorted(enabled_names)) or 'none'}); the first "
431 "enabled agent will be used as fallback."
432 )
434 if errors:
435 raise ConfigError("invalid configuration:\n - " + "\n - ".join(errors))
437 if strict and warnings:
438 raise ConfigError(
439 "configuration warnings treated as errors (strict mode):\n - "
440 + "\n - ".join(warnings)
441 )
443 return warnings
446@dataclass
447class AgentSpec:
448 name: str
449 vendor: str
450 command: str = ""
451 model: str | None = None
452 timeout: int = 600
453 enabled: bool = True
454 extra_args: list[str] = field(default_factory=list)
455 # OpenAI-compatible base URL for a local/open-weight or hosted API agent (issue #43).
456 endpoint: str | None = None
457 # Universal agent extensions
458 api_key_env: str | None = None
459 prompt_mode: str | None = None
460 headers: dict[str, str] = field(default_factory=dict)
463@dataclass
464class CiConfig:
465 fail_on: list[str] = field(default_factory=lambda: ["critical", "major"])
466 ignore_unverified: bool = True
469@dataclass
470class ContextConfig:
471 mode: str = "diff-only" # "diff-only" or "expanded"
472 redact_secrets: bool = True
475@dataclass
476class DiffConfig:
477 """Large-diff handling policy (issue #31).
479 ``max_bytes`` is the size (UTF-8 bytes, measured after filtering) above which
480 a diff is either chunked or rejected. ``chunk`` enables per-file chunking;
481 ``chunk_max_bytes`` bounds each chunk (defaults to ``max_bytes``).
482 ``exclude_generated`` drops binary and common generated/vendored files;
483 ``exclude``/``include`` are extra path-glob deny/allow lists.
484 """
486 max_bytes: int = 200_000
487 chunk: bool = False
488 chunk_max_bytes: int | None = None
489 exclude_generated: bool = True
490 exclude: list[str] = field(default_factory=list)
491 include: list[str] = field(default_factory=list)
494@dataclass
495class JuryConfig:
496 rounds: int = 2
497 chair: str = "claude"
498 timeout: int = 600
499 parallel: bool = True
500 verify: bool = True
501 agents: list[AgentSpec] = field(default_factory=list)
502 ci: CiConfig = field(default_factory=CiConfig)
503 context: ContextConfig = field(default_factory=ContextConfig)
504 diff: DiffConfig = field(default_factory=DiffConfig)
505 # Optional run seed. Controls the shared run RNG used by randomized
506 # orchestration features (see orchestrator.run_jury). LLM output itself
507 # is never made deterministic by this; only the orchestration around it.
508 seed: int | None = None
509 # Anonymize peer reviews shown in the round-2 debate (Chatham House rule,
510 # issue #37): strip vendor/agent identity, relabel as "Reviewer A/B/...",
511 # and randomize per-debater presentation order via the shared run RNG so
512 # neither identity nor position is a stable signal. The rendered report
513 # still attributes findings by real name. Set False for the old
514 # identity-labeled debate path.
515 anonymize_debate: bool = True
516 # Prefer a chair that was NOT a round-1 reviewer when a usable non-reviewer
517 # is available (issue #38), mitigating chair self-preference bias. Has no
518 # effect when chair == "rotate" (rotation already picks among usable agents)
519 # or when an explicit usable chair name is configured.
520 prefer_non_reviewer_chair: bool = False
521 # Demote a finding to non-blocking severity when every reviewer who raised it
522 # is vendor "local" and no cloud reviewer corroborates it (issue #442).
523 # Rejected alternative: a numeric per-reviewer trust weight — this categorical
524 # rule is auditable in one line where a coefficient invites silent drift.
525 # Off by default so the out-of-the-box CI gate is unchanged.
526 demote_local_only: bool = False
527 # Execution controls (issue #30). All optional and off by default so the
528 # out-of-the-box run is unchanged. ``total_timeout``/``phase_timeout`` cap the
529 # whole run / a single phase (None = uncapped); the effective per-agent-call
530 # timeout is the minimum of the agent timeout, the phase budget, and the
531 # remaining total budget. ``retries`` is the number of EXTRA attempts for
532 # transient (retryable) failures — 0 means try once.
533 total_timeout: int | None = None
534 phase_timeout: int | None = None
535 retries: int = 0
536 # Adaptive rounds (issue #40). When ``early_stop`` is True the orchestrator
537 # decides whether to run the debate round(s) from the round-1 convergence
538 # signal instead of always honouring a fixed ``rounds``: a unanimous panel
539 # stops after round 1, and disagreement runs debate up to ``max_rounds``.
540 # A CLI ``--rounds`` (or any explicit fixed-N intent) disables early stop so
541 # benchmarking stays reproducible. ``max_rounds`` defaults to ``rounds``.
542 max_rounds: int | None = None
543 early_stop: bool = False
544 # Risk-aware auto-depth (issue #120): when True, the CLI sets rounds/verify/
545 # early_stop from a cheap pre-review diff profile (size/paths/security), so a
546 # trivial diff runs shallow and a risky one runs full. Off by default; the
547 # panel is never trimmed; explicit --rounds/--verify/--early-stop override it.
548 auto_depth: bool = False
549 # Full-transcript output (issue: full transcript). When True, the markdown
550 # report defaults to the chronological play-by-play (each agent's raw review,
551 # the debate, and the chair's reasoning) instead of the consensus-first
552 # summary. Rendering-only: it does NOT affect orchestration, so it is
553 # deliberately excluded from ``config_hash`` and the cache key. The CLI
554 # ``--transcript``/``--no-transcript`` override it; ``--verbose`` is summary +
555 # transcript in one document.
556 transcript: bool = False
557 # Final-verdict mode (issue #220): "chair" = the chair's synthesis is the
558 # verdict (default, historical); "vote" = the panel verdict is a tally of the
559 # reviewers (each votes from the worst finding they raised). Rendering-only —
560 # it does not change orchestration, so it is excluded from ``config_hash`` and
561 # the cache key. The chair still runs (its reasoning is shown as supporting
562 # narrative), and the severity-based CI gate is unaffected. CLI: ``--decision``.
563 decision: str = "chair"
564 # Animated theater view defaults (issue #364). Rendering-only side channel —
565 # excluded from ``config_hash`` and the cache key (it never touches the
566 # outcome). ``theater`` defaults the scene on; ``theater_style`` is "flat"
567 # (ANSI line scene) or "pixel" (pixel-art room). The CLI ``--theater`` /
568 # ``--theater-style`` flags override these per run. Theater is TTY-only, so
569 # even when defaulted on it falls back to ``--live`` off an interactive
570 # terminal (and ``pixel`` falls back to ``flat`` without truecolor/unicode).
571 theater: bool = False
572 theater_style: str = "flat"
573 # Risk-aware tiered model routing (issue #524): "standard" (uniform panel) | "tiered" (cost-optimized with frontier anchor)
574 routing: str = "standard"
575 # Pre-pass static analysis hints (issue #523): inject linter hints into prompt context
576 hints: bool = False
578 @property
579 def effective_max_rounds(self) -> int:
580 """Round ceiling for adaptive mode: ``max_rounds`` or ``rounds``."""
581 return self.max_rounds if self.max_rounds is not None else self.rounds
583 @property
584 def enabled_agents(self) -> list[AgentSpec]:
585 return [a for a in self.agents if a.enabled]
588def _ci_from_dict(data: dict) -> CiConfig:
589 fail_on = data.get("fail_on", ["critical", "major"])
590 if not isinstance(fail_on, list):
591 fail_on = [fail_on]
592 fail_on = [str(s).strip().lower() for s in fail_on if str(s).strip()]
593 return CiConfig(
594 fail_on=fail_on,
595 ignore_unverified=bool(data.get("ignore_unverified", True)),
596 )
599def _context_from_dict(data: dict) -> ContextConfig:
600 mode = str(data.get("mode", "diff-only")).strip().lower()
601 if mode not in ("diff-only", "expanded"):
602 mode = "diff-only"
603 return ContextConfig(mode=mode, redact_secrets=bool(data.get("redact_secrets", True)))
606def _str_list(value) -> list[str]:
607 """Coerce a config value into a clean list of non-empty strings."""
608 if isinstance(value, str):
609 value = [value]
610 if not isinstance(value, list):
611 return []
612 return [str(v).strip() for v in value if str(v).strip()]
615def _diff_from_dict(data: dict) -> DiffConfig:
616 default = DiffConfig()
617 return DiffConfig(
618 max_bytes=_opt_positive_int(data.get("max_bytes")) or default.max_bytes,
619 chunk=bool(data.get("chunk", default.chunk)),
620 chunk_max_bytes=_opt_positive_int(data.get("chunk_max_bytes")),
621 exclude_generated=bool(data.get("exclude_generated", default.exclude_generated)),
622 exclude=_str_list(data.get("exclude", [])),
623 include=_str_list(data.get("include", [])),
624 )
627def _seed_from_dict(jury: dict) -> int | None:
628 """Parse ``[jury] seed`` into an int, or None when absent/invalid.
630 A non-integer or boolean seed is treated as "no seed" rather than an error:
631 the seed only governs orchestration randomness, so a malformed value should
632 degrade gracefully to the unseeded (still deterministic-orchestration) path.
633 """
634 raw = jury.get("seed")
635 if raw is None or isinstance(raw, bool):
636 return None
637 try:
638 return int(raw)
639 except (TypeError, ValueError):
640 return None
643def _opt_positive_int(raw) -> int | None:
644 """Coerce an optional positive-int config value, else None.
646 Used for the optional execution budgets (issue #30) and ``max_rounds``
647 (issue #40). A missing, boolean, non-numeric, or non-positive value degrades
648 to None (uncapped) rather than raising, so ``_from_dict`` stays tolerant when
649 called without validation; :func:`validate_config` is what reports the hard
650 error for an explicit bad value.
651 """
652 if raw is None or isinstance(raw, bool):
653 return None
654 try:
655 value = int(raw)
656 except (TypeError, ValueError):
657 return None
658 return value if value > 0 else None
661def _from_dict(data: dict) -> JuryConfig:
662 jury = data.get("jury", {})
663 default_timeout = int(jury.get("timeout", 600))
664 agents: list[AgentSpec] = []
665 for raw in data.get("agent", []):
666 raw_headers = raw.get("headers", {})
667 headers_dict = {str(k): str(v) for k, v in raw_headers.items()} if isinstance(raw_headers, dict) else {}
668 api_key_env_val = str(raw["api_key_env"]) if raw.get("api_key_env") else None
669 prompt_mode_val = str(raw["prompt_mode"]) if raw.get("prompt_mode") else None
670 agents.append(
671 AgentSpec(
672 name=raw["name"],
673 vendor=raw.get("vendor", "unknown"),
674 # ``command`` is optional for local/HTTP agents (issue #43).
675 command=raw.get("command", ""),
676 model=raw.get("model"),
677 timeout=int(raw.get("timeout", default_timeout)),
678 enabled=bool(raw.get("enabled", True)),
679 extra_args=list(raw.get("extra_args", [])),
680 endpoint=raw.get("endpoint"),
681 api_key_env=api_key_env_val,
682 prompt_mode=prompt_mode_val,
683 headers=headers_dict,
684 )
685 )
686 return JuryConfig(
687 rounds=int(jury.get("rounds", 2)),
688 chair=jury.get("chair", agents[0].name if agents else "claude"),
689 timeout=default_timeout,
690 parallel=bool(jury.get("parallel", True)),
691 verify=bool(jury.get("verify", True)),
692 agents=agents,
693 ci=_ci_from_dict(jury.get("ci", {})),
694 context=_context_from_dict(jury.get("context", {})),
695 diff=_diff_from_dict(jury.get("diff", {})),
696 seed=_seed_from_dict(jury),
697 anonymize_debate=bool(jury.get("anonymize_debate", True)),
698 prefer_non_reviewer_chair=bool(jury.get("prefer_non_reviewer_chair", False)),
699 demote_local_only=bool(jury.get("demote_local_only", False)),
700 total_timeout=_opt_positive_int(jury.get("total_timeout")),
701 phase_timeout=_opt_positive_int(jury.get("phase_timeout")),
702 retries=max(0, int(jury.get("retries", 0) or 0)),
703 max_rounds=_opt_positive_int(jury.get("max_rounds")),
704 early_stop=bool(jury.get("early_stop", False)),
705 auto_depth=bool(jury.get("auto_depth", False)),
706 transcript=bool(jury.get("transcript", False)),
707 decision=(str(jury.get("decision", "chair")).strip().lower() or "chair"),
708 theater=bool(jury.get("theater", False)),
709 theater_style=(str(jury.get("theater_style", "flat")).strip().lower() or "flat"),
710 routing=(str(jury.get("routing", "standard")).strip().lower() or "standard"),
711 hints=bool(jury.get("hints", False)),
712 )
715def config_hash(config: JuryConfig) -> str:
716 """Return a stable SHA-256 hash of the EFFECTIVE jury configuration.
718 The hash is a function of the resolved configuration only (no timestamps,
719 no diff text), so the same config always produces the same digest and a
720 changed config produces a different one. This anchors reproducibility
721 metadata: two runs with an identical config hash were orchestrated under
722 identical settings.
724 The seed is intentionally excluded so the hash describes the *configuration*
725 independent of which run seed was chosen; the seed is recorded separately in
726 run metadata.
727 """
728 import hashlib
729 import json
731 canonical = {
732 "rounds": config.rounds,
733 "chair": config.chair,
734 "timeout": config.timeout,
735 "parallel": config.parallel,
736 "verify": config.verify,
737 "total_timeout": config.total_timeout,
738 "phase_timeout": config.phase_timeout,
739 "retries": config.retries,
740 "max_rounds": config.max_rounds,
741 "early_stop": config.early_stop,
742 "auto_depth": config.auto_depth,
743 # Orchestration-affecting toggles (issue #122): both change how a run is
744 # conducted, so the "same hash ⇒ same orchestration" promise must include
745 # them.
746 "anonymize_debate": config.anonymize_debate,
747 "prefer_non_reviewer_chair": config.prefer_non_reviewer_chair,
748 "demote_local_only": config.demote_local_only,
749 "ci": {
750 "fail_on": list(config.ci.fail_on),
751 "ignore_unverified": config.ci.ignore_unverified,
752 },
753 "context": {
754 "mode": config.context.mode,
755 "redact_secrets": config.context.redact_secrets,
756 },
757 "diff": {
758 "max_bytes": config.diff.max_bytes,
759 "chunk": config.diff.chunk,
760 "chunk_max_bytes": config.diff.chunk_max_bytes,
761 "exclude_generated": config.diff.exclude_generated,
762 "exclude": list(config.diff.exclude),
763 "include": list(config.diff.include),
764 },
765 "agents": [
766 {
767 "name": a.name,
768 "vendor": a.vendor,
769 "command": a.command,
770 "endpoint": a.endpoint,
771 "model": a.model,
772 "timeout": a.timeout,
773 "enabled": a.enabled,
774 "extra_args": list(a.extra_args),
775 }
776 for a in config.agents
777 ],
778 }
779 payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
780 return hashlib.sha256(payload.encode("utf-8")).hexdigest()
783def load_raw_config(path: str | Path | None = None) -> dict:
784 """Return the raw config dict for *path*, or the built-in default.
786 If *path* is None, look for ``jury.toml`` in the current directory and
787 fall back to :data:`DEFAULT_CONFIG` when it is absent. An explicit *path*
788 that does not exist raises ``FileNotFoundError``.
789 """
790 if path is None:
791 candidate = Path("jury.toml")
792 if not candidate.exists():
793 return DEFAULT_CONFIG
794 path = candidate
795 path = Path(path)
796 if not path.exists():
797 raise FileNotFoundError(f"Config not found: {path}")
798 return _read_toml_bounded(path)
801def load_config(
802 path: str | Path | None = None,
803 validate: bool = False,
804 strict: bool = False,
805) -> JuryConfig:
806 """Load jury config from *path*, or fall back to the built-in default.
808 If *path* is None, look for ``jury.toml`` in the current directory.
810 When *validate* is True, the resolved config dict is checked with
811 :func:`validate_config` before being materialized; a ``ConfigError`` is
812 raised on hard-invalid input (and on warnings when *strict* is True).
813 Validation is opt-in so existing callers stay unaffected.
814 """
815 data = load_raw_config(path)
816 if validate:
817 validate_config(data, strict=strict)
818 return _from_dict(data)