Coverage for src/keel/config.py: 100%

197 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +0000

1"""Load + validate a keel ``project.yaml`` into a typed, immutable config. 

2 

3Pure and deterministic: parsing the same YAML always yields the same 

4``ProjectConfig`` and the same :func:`config_hash`. The only I/O is reading the 

5file in :func:`load_config`; everything else operates on plain data so it is 

6trivially unit-testable. 

7""" 

8 

9from __future__ import annotations 

10 

11import hashlib 

12import json 

13import os 

14from dataclasses import dataclass, field 

15from pathlib import Path 

16from typing import Any 

17from urllib.parse import urlsplit 

18 

19from . import jsonschema_min 

20from . import yaml_helper as yaml 

21from .capabilities import validate_names 

22 

23# Keep both names coming from `model` — the one module with no intra-package imports. 

24# Taking DEFAULT_GATE_TIMEOUT_S from `gates` instead closes a config -> gates -> config 

25# cycle (gates names config in its TYPE_CHECKING imports). SLOTS: source of truth for 

26# the named slots; DEFAULT_GATE_TIMEOUT_S: shared with the gate planner and runner. 

27from .model import DEFAULT_GATE_TIMEOUT_S, DEFAULT_JURY_TIMEOUT_S, SLOTS 

28 

29SCHEMA_PATH = Path(__file__).parent / "schema" / "project.schema.json" 

30 

31DEFAULT_EXTENSIONS_DIR = ".keel/extensions" 

32 

33#: Vendors a ``knobs.delegate_profiles`` entry may declare. ``cli`` drives a local 

34#: coding-agent CLI (#659); ``openai-compatible`` reaches any OpenAI-shaped hosted API 

35#: — OpenRouter, Groq, DeepSeek, Together, LiteLLM, vLLM — from config (#666). 

36DELEGATE_PROFILE_VENDORS = ("cli", "openai-compatible") 

37 

38#: Vendors whose profile must name an executable. 

39_COMMAND_VENDORS = ("cli",) 

40#: Vendors whose profile must name an endpoint + the env var holding its key. 

41_ENDPOINT_VENDORS = ("openai-compatible",) 

42 

43#: Hosts an ``openai-compatible`` endpoint may use without an explicit opt-in. 

44LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1", "[::1]") 

45 

46#: Environment opt-in for a **non-loopback** endpoint. It lives in the environment and 

47#: deliberately **not** in ``project.yaml``: the threat model here is an 

48#: attacker-influenced config, so the switch that permits reaching a remote host must 

49#: sit outside the surface an attacker would control. Ported from ai-jury's 

50#: ``JURY_ALLOW_REMOTE_ENDPOINT`` (same reasoning, same default-closed posture). 

51ALLOW_REMOTE_ENDPOINT_ENV = "KEEL_ALLOW_REMOTE_ENDPOINT" 

52 

53#: How a ``cli`` profile's prompt reaches the command. ``stdin`` stays the default 

54#: (positional-arg passing hangs some CLIs); ``arg`` is the opt-in for CLIs whose usage 

55#: makes the prompt a positional argument (e.g. ``cursor-agent``). 

56DELEGATE_PROMPT_MODES = ("stdin", "arg") 

57DEFAULT_PROMPT_MODE = "stdin" 

58 

59#: Flag a ``cli`` profile's command takes the model on. Near-universal across coding-agent 

60#: CLIs (``cursor-agent``, ``gemini``, Aider all spell it ``--model``), but configurable 

61#: because "arbitrary CLI" is the whole point and nothing guarantees the spelling. 

62DEFAULT_MODEL_ARG = "--model" 

63 

64__all__ = ["SLOTS", "DEFAULT_EXTENSIONS_DIR", "DELEGATE_PROFILE_VENDORS", 

65 "LOOPBACK_HOSTS", "ALLOW_REMOTE_ENDPOINT_ENV", 

66 "DELEGATE_PROMPT_MODES", "DEFAULT_PROMPT_MODE", "DEFAULT_MODEL_ARG", 

67 "Automation", "DelegateProfile", 

68 "Knobs", "ProjectConfig", "ConfigError", "load_config", "parse_config", 

69 "validate_data", "load_schema", "config_hash", "delegate_profiles_dict"] 

70 

71 

72class ConfigError(ValueError): 

73 """Raised when a project config fails schema validation.""" 

74 

75 def __init__(self, source: str, errors: list[str]): 

76 self.source = source 

77 self.errors = list(errors) 

78 joined = "\n - ".join(self.errors) 

79 super().__init__(f"invalid keel config {source}:\n - {joined}") 

80 

81 

82def load_schema() -> dict: 

83 """Load the bundled JSON Schema for ``project.yaml``.""" 

84 return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) 

85 

86 

87def validate_data(data: Any, schema: dict | None = None) -> list[str]: 

88 """Return schema-validation errors for raw config data (empty == valid).""" 

89 return jsonschema_min.validate(data, schema if schema is not None else load_schema()) 

90 

91 

92@dataclass(frozen=True) 

93class DelegateProfile: 

94 """A named generic-delegate vendor, referenced as ``--delegate <name>``. 

95 

96 Turns provider support into configuration: a ``cli`` profile names a local 

97 coding-agent CLI (``command``) and how its prompt is delivered (``prompt_mode``), 

98 so ``cursor-agent``/``gemini``/Aider/Goose become config entries rather than code 

99 changes. ``command`` is operator-authored config with the same trust level as 

100 ``build_gate_cmd`` — it is never taken from PR content or agent output. 

101 """ 

102 

103 vendor: str 

104 command: str | None = None 

105 #: Fixed flags the command always needs, e.g. ``["-p", "--force"]`` for 

106 #: ``cursor-agent`` (print mode + non-interactive approval). ``command`` is one 

107 #: executable, so without this an operator would have to smuggle flags into it as a 

108 #: string keel would then treat as a filename. 

109 args: tuple[str, ...] = () 

110 #: Flags for the **reviewer** role, when they must differ from ``args``. s7 asks a 

111 #: reviewer for findings only, but ``args`` typically carries the implementer's 

112 #: write-enabling flags (``--force`` approves edits non-interactively). Falls back to 

113 #: ``args`` when unset — and keel cannot *enforce* read-only for an arbitrary CLI, so 

114 #: this is the operator's lever, not a guarantee. See :meth:`role_args`. 

115 review_args: tuple[str, ...] | None = None 

116 prompt_mode: str = DEFAULT_PROMPT_MODE 

117 model: str | None = None 

118 #: How the effective model reaches the command: ``<model_arg> <model>``. Without it 

119 #: the documented model precedence would be unimplementable for an arbitrary CLI — 

120 #: attribution would record a model that was never actually selected. 

121 model_arg: str = DEFAULT_MODEL_ARG 

122 #: ``openai-compatible`` only: the OpenAI-shaped chat-completions URL. Validated 

123 #: by :func:`endpoint_issues` — loopback by default, remote behind an env opt-in. 

124 endpoint: str | None = None 

125 #: ``openai-compatible`` only: the **name** of the env var holding the API key. 

126 #: Never the key. Profile config is serialised into the command contract and 

127 #: hashed into ``config_hash``, so a value here would be published. 

128 api_key_env: str | None = None 

129 

130 def role_args(self, *, review: bool = False) -> tuple[str, ...]: 

131 """Flags for this role: ``review_args`` for a reviewer when set, else ``args``.""" 

132 if review and self.review_args is not None: 

133 return self.review_args 

134 return self.args 

135 

136 

137@dataclass(frozen=True) 

138class Knobs: 

139 """Per-project values consumed by the (otherwise neutral) backbone steps.""" 

140 

141 build_gate_cmd: str 

142 lint_cmd: str | None = None 

143 implementer_agents: dict[str, str] = field(default_factory=dict) 

144 #: Profile name -> generic delegate vendor config. Never shadows a built-in vendor 

145 #: (``claude``/``codex``/``agy``/``ollama``/``*-api``); that is a validation error. 

146 delegate_profiles: dict[str, DelegateProfile] = field(default_factory=dict) 

147 tier3_globs: tuple[str, ...] = () 

148 ci_workflows: dict[str, str] = field(default_factory=dict) 

149 docs_gate_paths: tuple[str, ...] = () 

150 docs_only_allowlist: tuple[str, ...] = () 

151 sot_doc: str | None = None 

152 required_capabilities: tuple[str, ...] = () 

153 optional_capabilities: tuple[str, ...] = () 

154 evidence_gate_label: str = "keel:ship" 

155 evidence_require_distinct_vendors: bool = False 

156 #: Wall-clock seconds a command gate may run before it is killed. Raise this on a 

157 #: slow host; a single slower gate can override it with ``timeout:`` frontmatter. 

158 gate_timeout_s: int = DEFAULT_GATE_TIMEOUT_S 

159 #: Wall-clock seconds the ``jury`` built-in may run. Separate from gate_timeout_s: 

160 #: a cross-vendor panel and a test suite have unrelated runtimes. 

161 jury_timeout_s: int = DEFAULT_JURY_TIMEOUT_S 

162 

163 

164@dataclass(frozen=True) 

165class Automation: 

166 """Trusted unattended-run consent defaults.""" 

167 

168 approved_scopes: tuple[str, ...] = () 

169 operator: str | None = None 

170 

171 

172@dataclass(frozen=True) 

173class ProjectConfig: 

174 """A resolved, immutable keel project config.""" 

175 

176 extends: str 

177 core_version: str 

178 base_branch: str 

179 knobs: Knobs 

180 owner: str | None = None 

181 repo: str | None = None 

182 platform: str | None = None 

183 timezone: str | None = None 

184 merge_window: str | None = None 

185 merge_window_mode: str = "freeze" 

186 consent_mode: str = "explicit" 

187 gates: tuple[str, ...] = () 

188 extensions: dict[str, tuple[str, ...]] = field(default_factory=dict) 

189 extensions_dir: str = DEFAULT_EXTENSIONS_DIR 

190 policy_pack: dict[str, Any] = field(default_factory=dict) 

191 automation: Automation = field(default_factory=Automation) 

192 

193 def slot(self, name: str) -> tuple[str, ...]: 

194 """Extension files registered for a named slot (``()`` if none).""" 

195 if name not in SLOTS: 

196 raise KeyError(f"unknown slot {name!r}; valid slots: {', '.join(SLOTS)}") 

197 return self.extensions.get(name, ()) 

198 

199 

200def _build(data: dict) -> ProjectConfig: 

201 k = data["knobs"] 

202 knobs = Knobs( 

203 build_gate_cmd=k["build_gate_cmd"], 

204 lint_cmd=k.get("lint_cmd"), 

205 implementer_agents=dict(k.get("implementer_agents", {})), 

206 delegate_profiles={ 

207 name: DelegateProfile( 

208 vendor=profile["vendor"], 

209 command=profile.get("command"), 

210 args=tuple(profile.get("args", ())), 

211 # An explicit null round-trips as "unset" — distinct from [], which 

212 # means "the reviewer takes no flags at all". 

213 review_args=( 

214 tuple(profile["review_args"]) 

215 if profile.get("review_args") is not None 

216 else None 

217 ), 

218 prompt_mode=profile.get("prompt_mode", DEFAULT_PROMPT_MODE), 

219 model=profile.get("model"), 

220 model_arg=profile.get("model_arg") or DEFAULT_MODEL_ARG, 

221 endpoint=profile.get("endpoint"), 

222 api_key_env=profile.get("api_key_env"), 

223 ) 

224 for name, profile in k.get("delegate_profiles", {}).items() 

225 }, 

226 tier3_globs=tuple(k.get("tier3_globs", [])), 

227 ci_workflows=dict(k.get("ci_workflows", {})), 

228 docs_gate_paths=tuple(k.get("docs_gate_paths", [])), 

229 docs_only_allowlist=tuple(k.get("docs_only_allowlist", [])), 

230 sot_doc=k.get("sot_doc"), 

231 required_capabilities=tuple(k.get("required_capabilities", [])), 

232 optional_capabilities=tuple(k.get("optional_capabilities", [])), 

233 evidence_gate_label=k.get("evidence_gate_label", "keel:ship"), 

234 evidence_require_distinct_vendors=bool(k.get("evidence_require_distinct_vendors", False)), 

235 gate_timeout_s=int(k.get("gate_timeout_s", DEFAULT_GATE_TIMEOUT_S)), 

236 jury_timeout_s=int(k.get("jury_timeout_s", DEFAULT_JURY_TIMEOUT_S)), 

237 ) 

238 extensions = {slot: tuple(files) for slot, files in data.get("extensions", {}).items()} 

239 automation_data = data.get("automation", {}) 

240 automation_scopes = tuple(dict.fromkeys(automation_data.get("approved_scopes", []))) 

241 return ProjectConfig( 

242 extends=data["extends"], 

243 core_version=data["core_version"], 

244 base_branch=data["base_branch"], 

245 knobs=knobs, 

246 owner=data.get("owner"), 

247 repo=data.get("repo"), 

248 platform=data.get("platform"), 

249 timezone=data.get("timezone"), 

250 merge_window=data.get("merge_window"), 

251 merge_window_mode=data.get("merge_window_mode", "freeze"), 

252 consent_mode=data.get("consent_mode", "explicit"), 

253 gates=tuple(data.get("gates", [])), 

254 extensions=extensions, 

255 extensions_dir=data.get("extensions_dir", DEFAULT_EXTENSIONS_DIR), 

256 policy_pack=json.loads(json.dumps(data.get("policy_pack", {}), sort_keys=True)), 

257 automation=Automation( 

258 approved_scopes=tuple(sorted(automation_scopes)), 

259 operator=automation_data.get("operator"), 

260 ), 

261 ) 

262 

263 

264def parse_config(data: Any, *, source: str = "<dict>", schema: dict | None = None) -> ProjectConfig: 

265 """Validate raw data and build a :class:`ProjectConfig` (raises on error).""" 

266 if not isinstance(data, dict): 

267 raise ConfigError(source, [f"$: expected an object (got {type(data).__name__})"]) 

268 errors = validate_data(data, schema) 

269 if isinstance(data, dict) and isinstance(data.get("knobs"), dict): 

270 knobs = data["knobs"] 

271 errors.extend(validate_names( 

272 tuple(knobs.get("required_capabilities", [])), 

273 source=f"{source}: knobs.required_capabilities", 

274 )) 

275 errors.extend(validate_names( 

276 tuple(knobs.get("optional_capabilities", [])), 

277 source=f"{source}: knobs.optional_capabilities", 

278 )) 

279 errors.extend(_validate_delegate_profiles( 

280 knobs.get("delegate_profiles", {}), 

281 source=f"{source}: knobs.delegate_profiles", 

282 )) 

283 if isinstance(data, dict) and isinstance(data.get("policy_pack"), dict): 

284 for path, names in _policy_capability_fields(data["policy_pack"]): 

285 errors.extend(validate_names(tuple(names), source=f"{source}: {path}")) 

286 if errors: 

287 raise ConfigError(source, errors) 

288 return _build(data) 

289 

290 

291def load_config(path: str | Path) -> ProjectConfig: 

292 """Read + validate a ``project.yaml`` from disk.""" 

293 path = Path(path) 

294 data = yaml.load(path.read_text(encoding="utf-8")) 

295 return parse_config(data, source=str(path)) 

296 

297 

298def config_hash(config: ProjectConfig) -> str: 

299 """Stable SHA-256 over the canonicalised config (cache key / determinism).""" 

300 payload = json.dumps(_canonical(config), sort_keys=True, separators=(",", ":")) 

301 return hashlib.sha256(payload.encode("utf-8")).hexdigest() 

302 

303 

304def _is_env_var_name(value: str) -> bool: 

305 """Cheap shape check that ``api_key_env`` is a *name*, not a pasted secret.""" 

306 return bool(value) and not value[0].isdigit() and all( 

307 ch.isalnum() or ch == "_" for ch in value 

308 ) 

309 

310 

311def endpoint_issues(endpoint: Any, *, where: str, env=None) -> list[str]: 

312 """Validate an ``openai-compatible`` endpoint URL. Empty list == acceptable. 

313 

314 A config-supplied URL is the one genuinely new risk in #666: every other keel 

315 delegate talks to a hardcoded constant, which is why their SSRF story is trivial. 

316 Letting config name the host makes ``project.yaml`` a request-forgery primitive 

317 pointed wherever it says, including cloud-metadata addresses like 

318 ``169.254.169.254``. Ported from ai-jury's ``_endpoint_issues`` rather than 

319 reinvented — same decisions, same default-closed posture: 

320 

321 * a non-``http``/``https`` scheme is refused, which blocks ``file://``, ``ftp://`` 

322 and the other SSRF primitives; 

323 * a malformed URL is a config error, not a stack trace out of ``keel validate``; 

324 * a **non-loopback** host is refused unless the operator sets 

325 :data:`ALLOW_REMOTE_ENDPOINT_ENV` in the environment. The opt-in is env-only on 

326 purpose: an attacker who can edit config must not be able to grant it. 

327 

328 Plaintext ``http://`` to a permitted remote host is allowed but noted in the 

329 message, since the prompt (and the diff in it) would cross the network in clear. 

330 """ 

331 env = os.environ if env is None else env 

332 if not isinstance(endpoint, str) or not endpoint.strip(): 

333 return [f"{where}: vendor 'openai-compatible' requires a non-empty 'endpoint'"] 

334 try: 

335 parsed = urlsplit(endpoint) 

336 host = (parsed.hostname or "").lower() 

337 except ValueError: 

338 # urlsplit raises on e.g. "http://[::1" — by definition not a usable endpoint. 

339 return [f"{where}: endpoint {endpoint!r} is not a valid URL"] 

340 scheme = (parsed.scheme or "").lower() 

341 if scheme not in ("http", "https"): 

342 return [ 

343 f"{where}: endpoint scheme {parsed.scheme or '(none)'!r} is not allowed; " 

344 "use http or https" 

345 ] 

346 if host in LOOPBACK_HOSTS: 

347 return [] 

348 if not env.get(ALLOW_REMOTE_ENDPOINT_ENV): 

349 return [ 

350 f"{where}: endpoint host {host or '(none)'!r} is not loopback; a remote " 

351 "model server (including internal and cloud-metadata addresses) is refused " 

352 f"by default. Set {ALLOW_REMOTE_ENDPOINT_ENV}=1 in the environment — not in " 

353 "this file — to allow a trusted remote endpoint" 

354 ] 

355 return [] 

356 

357 

358def _validate_delegate_profiles(profiles: Any, *, source: str) -> list[str]: 

359 """Return semantic errors for ``knobs.delegate_profiles`` (empty == valid). 

360 

361 The schema owns the *shape* (object of objects, `vendor` required, field types); 

362 this owns the *meaning*: which vendors exist, what each vendor requires, and the 

363 fail-closed rule that a profile may never shadow a built-in delegate vendor. 

364 """ 

365 # Local import on purpose: ``agents`` imports this module for ``ProjectConfig``, so 

366 # naming it at module scope would close a real config <-> agents cycle. The vendor 

367 # vocabulary belongs next to the dispatch logic in ``agents``, so the import moves 

368 # instead of the constant (same pattern as ``runtime._api_token_capability``). 

369 from .agents import BUILTIN_DELEGATE_VENDORS 

370 

371 errors: list[str] = [] 

372 if not isinstance(profiles, dict): 

373 return errors # the schema already reported the wrong shape 

374 for name, profile in profiles.items(): 

375 where = f"{source}.{name}" 

376 # A YAML mapping key is not necessarily a string: SafeLoader resolves an 

377 # unquoted ``on:``/``2:``/``~:`` to bool/int/None, and the JSON schema validates 

378 # property *values* only, never key types. So this has to be the first check — 

379 # everything below assumes ``str`` methods, and reaching them with a bool raised 

380 # an uncaught AttributeError out of ``keel validate``. 

381 if not isinstance(name, str): 

382 errors.append( 

383 f"{source}: delegate profile name {name!r} is {type(name).__name__}, not a " 

384 "string — quote the key (YAML reads a bare on/off/yes/no/true/false as a " 

385 "boolean and a bare number as an int)" 

386 ) 

387 continue 

388 if name in BUILTIN_DELEGATE_VENDORS: 

389 errors.append( 

390 f"{where}: profile name {name!r} shadows a built-in delegate vendor; " 

391 f"built-ins always win and may not be redefined " 

392 f"({', '.join(BUILTIN_DELEGATE_VENDORS)}) — rename the profile" 

393 ) 

394 elif name in DELEGATE_PROFILE_VENDORS: 

395 # `delegate_profile` exists to say *which* CLI ran. A profile named after its 

396 # own vendor makes every attribution field read "cli", which is exactly the 

397 # ambiguity the field was added to remove. 

398 errors.append( 

399 f"{where}: profile name {name!r} is a delegate vendor name and would make " 

400 "attribution ambiguous — agent:cli, system 'cli' and delegate_profile " 

401 "'cli' would all say the same nothing. Name it after the CLI, e.g. " 

402 "'cursor'" 

403 ) 

404 # A name that can never be selected is a config error, not a silent dead entry: 

405 # ``--delegate`` is split on the first colon, so a name containing one resolves 

406 # to a different (missing) profile, and an empty name reads as no delegate at all. 

407 if not name.strip(): 

408 errors.append( 

409 f"{source}: a delegate profile name may not be empty or blank — " 

410 "an empty --delegate reads as no delegate at all" 

411 ) 

412 elif ":" in name: 

413 errors.append( 

414 f"{where}: profile name {name!r} may not contain ':' — --delegate splits " 

415 "on the first colon to separate the profile from a per-run model, so this " 

416 "name could never be selected" 

417 ) 

418 if not isinstance(profile, dict) or "vendor" not in profile: 

419 continue # shape + required-field errors are the schema's job 

420 vendor = profile["vendor"] 

421 if vendor not in DELEGATE_PROFILE_VENDORS: 

422 errors.append( 

423 f"{where}: unknown delegate vendor {vendor!r}; " 

424 f"valid: {', '.join(DELEGATE_PROFILE_VENDORS)}" 

425 ) 

426 elif vendor in _COMMAND_VENDORS and not profile.get("command"): 

427 errors.append( 

428 f"{where}: vendor {vendor!r} requires a non-empty 'command' — the " 

429 "executable keel runs (e.g. cursor-agent)" 

430 ) 

431 elif vendor in _ENDPOINT_VENDORS: 

432 errors.extend(endpoint_issues(profile.get("endpoint"), where=where)) 

433 key_env = profile.get("api_key_env") 

434 if not key_env or not isinstance(key_env, str) or not key_env.strip(): 

435 errors.append( 

436 f"{where}: vendor {vendor!r} requires 'api_key_env' — the *name* of " 

437 "the environment variable holding the key. Never the key itself: " 

438 "profile config is serialised into the command contract and hashed " 

439 "into config_hash, so a value here would be published" 

440 ) 

441 elif not _is_env_var_name(key_env): 

442 errors.append( 

443 f"{where}: api_key_env {key_env!r} is not a valid environment " 

444 "variable name (letters, digits, underscore; not starting with a " 

445 "digit) — this field takes a name, not a key" 

446 ) 

447 # A field that does not apply to this vendor is a config error, not a 

448 # silently-ignored key: an operator who sets `endpoint` on a `cli` profile has 

449 # a mistaken model of what will run, and the schema cannot catch it because 

450 # both fields are legal *somewhere*. 

451 for field_name, owners in (("command", _COMMAND_VENDORS), 

452 ("endpoint", _ENDPOINT_VENDORS), 

453 ("api_key_env", _ENDPOINT_VENDORS)): 

454 if profile.get(field_name) and vendor in DELEGATE_PROFILE_VENDORS \ 

455 and vendor not in owners: 

456 errors.append( 

457 f"{where}: {field_name!r} does not apply to vendor {vendor!r} " 

458 f"(only {', '.join(owners)}) — it would be silently ignored" 

459 ) 

460 prompt_mode = profile.get("prompt_mode", DEFAULT_PROMPT_MODE) 

461 if prompt_mode not in DELEGATE_PROMPT_MODES: 

462 errors.append( 

463 f"{where}: invalid prompt_mode {prompt_mode!r}; " 

464 f"valid: {', '.join(DELEGATE_PROMPT_MODES)}" 

465 ) 

466 return errors 

467 

468 

469def _policy_capability_fields(value: Any, path: str = "policy_pack") -> list[tuple[str, list]]: 

470 fields: list[tuple[str, list]] = [] 

471 if isinstance(value, dict): 

472 for key, child in value.items(): 

473 child_path = f"{path}.{key}" 

474 if ( 

475 key in {"required_capabilities", "optional_capabilities"} 

476 and isinstance(child, list) 

477 ): 

478 fields.append((child_path, child)) 

479 else: 

480 fields.extend(_policy_capability_fields(child, child_path)) 

481 elif isinstance(value, list): 

482 for i, child in enumerate(value): 

483 fields.extend(_policy_capability_fields(child, f"{path}[{i}]")) 

484 return fields 

485 

486 

487def delegate_profiles_dict(config: ProjectConfig) -> dict: 

488 """``{"delegate_profiles": {...}}``, or ``{}`` when none are configured. 

489 

490 Shared by :func:`_canonical` and ``contracts.project_as_dict`` so the hashed form 

491 and the published contract cannot drift apart. Empty means **absent**, not ``{}``: 

492 an added optional field must not change ``config_hash`` for projects that never 

493 used it. 

494 """ 

495 profiles = config.knobs.delegate_profiles 

496 if not profiles: 

497 return {} 

498 return { 

499 "delegate_profiles": { 

500 name: { 

501 "vendor": profile.vendor, 

502 "command": profile.command, 

503 "args": list(profile.args), 

504 "review_args": ( 

505 list(profile.review_args) if profile.review_args is not None else None 

506 ), 

507 "prompt_mode": profile.prompt_mode, 

508 "model": profile.model, 

509 "model_arg": profile.model_arg, 

510 "endpoint": profile.endpoint, 

511 "api_key_env": profile.api_key_env, 

512 } 

513 for name, profile in sorted(profiles.items()) 

514 } 

515 } 

516 

517 

518def _canonical(config: ProjectConfig) -> dict: 

519 return { 

520 "extends": config.extends, 

521 "core_version": config.core_version, 

522 "base_branch": config.base_branch, 

523 "owner": config.owner, 

524 "repo": config.repo, 

525 "platform": config.platform, 

526 "timezone": config.timezone, 

527 "merge_window": config.merge_window, 

528 "merge_window_mode": config.merge_window_mode, 

529 "consent_mode": config.consent_mode, 

530 "gates": list(config.gates), 

531 "extensions_dir": config.extensions_dir, 

532 "extensions": {k: list(v) for k, v in sorted(config.extensions.items())}, 

533 "policy_pack": config.policy_pack, 

534 "automation": { 

535 "approved_scopes": list(config.automation.approved_scopes), 

536 "operator": config.automation.operator, 

537 }, 

538 "knobs": { 

539 "build_gate_cmd": config.knobs.build_gate_cmd, 

540 "lint_cmd": config.knobs.lint_cmd, 

541 "implementer_agents": dict(sorted(config.knobs.implementer_agents.items())), 

542 # Omitted entirely when empty: emitting "delegate_profiles": {} would rotate 

543 # config_hash for every project that has never configured one, which is the 

544 # normal treatment for an added optional field. 

545 **delegate_profiles_dict(config), 

546 "tier3_globs": list(config.knobs.tier3_globs), 

547 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())), 

548 "docs_gate_paths": list(config.knobs.docs_gate_paths), 

549 "docs_only_allowlist": list(config.knobs.docs_only_allowlist), 

550 "sot_doc": config.knobs.sot_doc, 

551 "required_capabilities": list(config.knobs.required_capabilities), 

552 "optional_capabilities": list(config.knobs.optional_capabilities), 

553 "evidence_gate_label": config.knobs.evidence_gate_label, 

554 "evidence_require_distinct_vendors": config.knobs.evidence_require_distinct_vendors, 

555 "gate_timeout_s": config.knobs.gate_timeout_s, 

556 "jury_timeout_s": config.knobs.jury_timeout_s, 

557 }, 

558 }