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

1"""Configuration loading for the jury. 

2 

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""" 

6 

7from __future__ import annotations 

8 

9import os 

10import tomllib 

11from dataclasses import dataclass, field 

12from pathlib import Path 

13from urllib.parse import urlsplit 

14 

15from .redaction import redact 

16 

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]") 

19 

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 

25 

26 

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 

44 

45 

46def _is_relative_path_command(command: str) -> bool: 

47 """True for a relative command that contains a path separator (#293/F-6). 

48 

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() 

56 

57 

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" 

65 

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" 

71 

72 

73def _endpoint_issues(endpoint: str, label: str) -> tuple[list[str], list[str]]: 

74 """Validate a local-agent ``endpoint`` URL (issue #291, SSRF defense). 

75 

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 

125 

126 

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} 

177 

178 

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") 

184 

185KNOWN_VENDORS = ( 

186 "anthropic", "openai", "google", "local", 

187 "anthropic-api", "openai-api", "google-api", 

188 "openai-compatible", "cli", 

189) 

190 

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) 

239 

240 

241class ConfigError(Exception): 

242 """Raised when a jury configuration is invalid.""" 

243 

244 

245def validate_config(data: dict, strict: bool = False) -> list: 

246 """Validate a raw config dict. 

247 

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). 

252 

253 When ``strict`` is True, soft issues raise ``ConfigError`` instead of 

254 being returned as warnings. 

255 """ 

256 warnings: list = [] 

257 errors: list = [] 

258 

259 if not isinstance(data, dict): 

260 raise ConfigError("config root must be a table/dict.") 

261 

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 ) 

269 

270 jury = data.get("jury", {}) 

271 if not isinstance(jury, dict): 

272 raise ConfigError("[jury] must be a table.") 

273 

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 ) 

279 

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}).") 

284 

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}).") 

289 

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}).") 

299 

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}).") 

304 

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}).") 

312 

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}).") 

319 

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}).") 

329 

330 agents_data = data.get("agent", []) 

331 if not isinstance(agents_data, list): 

332 raise ConfigError("[[agent]] must be an array of tables.") 

333 

334 # At least one agent (hard). 

335 if not agents_data: 

336 errors.append("no agents configured; define at least one [[agent]] entry.") 

337 

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 

344 

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 ) 

351 

352 name = agent.get("name", "") 

353 label = name or f"agent[{idx}]" 

354 

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) 

362 

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 ) 

405 

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 ) 

412 

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 ) 

420 

421 if name and agent.get("enabled", True): 

422 enabled_names.add(name) 

423 

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 ) 

433 

434 if errors: 

435 raise ConfigError("invalid configuration:\n - " + "\n - ".join(errors)) 

436 

437 if strict and warnings: 

438 raise ConfigError( 

439 "configuration warnings treated as errors (strict mode):\n - " 

440 + "\n - ".join(warnings) 

441 ) 

442 

443 return warnings 

444 

445 

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) 

461 

462 

463@dataclass 

464class CiConfig: 

465 fail_on: list[str] = field(default_factory=lambda: ["critical", "major"]) 

466 ignore_unverified: bool = True 

467 

468 

469@dataclass 

470class ContextConfig: 

471 mode: str = "diff-only" # "diff-only" or "expanded" 

472 redact_secrets: bool = True 

473 

474 

475@dataclass 

476class DiffConfig: 

477 """Large-diff handling policy (issue #31). 

478 

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 """ 

485 

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) 

492 

493 

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 

577 

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 

582 

583 @property 

584 def enabled_agents(self) -> list[AgentSpec]: 

585 return [a for a in self.agents if a.enabled] 

586 

587 

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 ) 

597 

598 

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))) 

604 

605 

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()] 

613 

614 

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 ) 

625 

626 

627def _seed_from_dict(jury: dict) -> int | None: 

628 """Parse ``[jury] seed`` into an int, or None when absent/invalid. 

629 

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 

641 

642 

643def _opt_positive_int(raw) -> int | None: 

644 """Coerce an optional positive-int config value, else None. 

645 

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 

659 

660 

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 ) 

713 

714 

715def config_hash(config: JuryConfig) -> str: 

716 """Return a stable SHA-256 hash of the EFFECTIVE jury configuration. 

717 

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. 

723 

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 

730 

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() 

781 

782 

783def load_raw_config(path: str | Path | None = None) -> dict: 

784 """Return the raw config dict for *path*, or the built-in default. 

785 

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) 

799 

800 

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. 

807 

808 If *path* is None, look for ``jury.toml`` in the current directory. 

809 

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)