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

348 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +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 ipaddress 

13import json 

14import os 

15import re 

16import socket 

17from dataclasses import dataclass, field 

18from datetime import time 

19from functools import lru_cache 

20from pathlib import Path 

21from typing import Any 

22from urllib.parse import urlsplit 

23from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 

24 

25# capture does not import this module — even under TYPE_CHECKING — so this 

26# edge is one-way. A reverse import is the py/cyclic-import CodeQL reports. 

27from . import capture, jsonschema_min 

28from . import tdd as tdd_mode 

29from . import team as team_policy 

30from . import yaml_helper as yaml 

31from .capabilities import validate_names 

32 

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

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

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

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

37from .model import DEFAULT_GATE_TIMEOUT_S, DEFAULT_JURY_TIMEOUT_S, SLOTS 

38from .vocab import BUILTIN_DELEGATE_VENDORS 

39from .window import parse_window 

40 

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

42 

43DEFAULT_EXTENSIONS_DIR = ".keel/extensions" 

44 

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

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

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

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

49 

50#: Characters a ``vendor_label`` may contain (#1129). It becomes the GitHub label 

51#: ``agent:<value>``, which ``attribution_check`` reads back and compares, so the set is 

52#: the one every built-in vendor token already uses: lowercase, digits, ``.``, ``-``, 

53#: ``_``. A ``:`` is excluded because it would split the label into a third segment. 

54_VENDOR_LABEL_OK = frozenset("abcdefghijklmnopqrstuvwxyz0123456789.-_") 

55 

56#: Vendors whose profile must name an executable. 

57_COMMAND_VENDORS = ("cli",) 

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

59_ENDPOINT_VENDORS = ("openai-compatible",) 

60 

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

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

63 

64#: High-privilege system credentials that delegate profiles may not target as API keys. 

65BLOCKED_ENV_KEY_NAMES = frozenset( 

66 { 

67 "GITHUB_TOKEN", 

68 "GH_TOKEN", 

69 "GITHUB_PAT", 

70 "AWS_SECRET_ACCESS_KEY", 

71 "AWS_ACCESS_KEY_ID", 

72 "AWS_SESSION_TOKEN", 

73 "SSH_AUTH_SOCK", 

74 "NPM_TOKEN", 

75 "PYPI_TOKEN", 

76 "SLACK_TOKEN", 

77 "DISCORD_TOKEN", 

78 } 

79) 

80 

81BLOCKED_ENV_PREFIXES = ( 

82 "GITHUB_", 

83 "GH_", 

84 "AWS_", 

85 "NPM_", 

86 "PYPI_", 

87 "SSH_", 

88) 

89 

90#: Env vars a delegate profile may name as its API key. #865 asked for **both** 

91#: this and the denylist above; only the denylist shipped, so `VAULT_TOKEN`, 

92#: `AZURE_CLIENT_SECRET`, `KUBECONFIG`, `DATABASE_URL` and `STRIPE_SECRET_KEY` 

93#: were all accepted and would have travelled as `Authorization: Bearer` to a 

94#: config-named endpoint (#929). 

95#: 

96#: The allowlist is the measure that matters, and #865 said so: eleven names and 

97#: six prefixes cannot enumerate every credential a runner holds, and the next 

98#: secret to appear in CI is one nobody added to the list. The denylist stays as 

99#: defence in depth — it catches a name that is *also* on the allowlist by 

100#: accident, and it fails with a message about the specific credential. 

101ALLOWED_ENV_KEY_NAMES = frozenset( 

102 { 

103 "OPENAI_API_KEY", 

104 "GROQ_API_KEY", 

105 "DEEPSEEK_API_KEY", 

106 "TOGETHER_API_KEY", 

107 "OPENROUTER_API_KEY", 

108 "LITELLM_API_KEY", 

109 "VLLM_API_KEY", 

110 } 

111) 

112 

113#: Escape hatch for a provider the list does not name yet. Prefixing is a 

114#: deliberate act on a variable created for this purpose, which is the property 

115#: the allowlist is protecting — an ambient runner secret does not carry it. 

116ALLOWED_ENV_KEY_PREFIX = "KEEL_DELEGATE_KEY_" 

117 

118 

119def _is_allowed_api_key_env(name: str) -> bool: 

120 """Whether ``name`` is a variable a delegate profile may read its key from.""" 

121 upper = name.upper() 

122 return upper in ALLOWED_ENV_KEY_NAMES or upper.startswith(ALLOWED_ENV_KEY_PREFIX) 

123 

124 

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

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

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

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

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

130#: 

131#: Its value is either a **boolean** (``1``/``true``/``yes``/``on`` — allow any remote 

132#: host, the broad legacy form) or a **host allowlist** (a comma- or space-separated 

133#: list of hostnames). With an allowlist, only the named hosts pass, so a config that 

134#: points the key host at some other server is refused *even while the opt-in is set* — 

135#: the coarse machine-wide boolean no longer lets one trusted remote authorise all of 

136#: them (#1247). 

137ALLOW_REMOTE_ENDPOINT_ENV = "KEEL_ALLOW_REMOTE_ENDPOINT" 

138 

139#: Values of :data:`ALLOW_REMOTE_ENDPOINT_ENV` that mean "allow any remote host". A single 

140#: token matching one of these is the boolean opt-in; anything else non-empty is read as a 

141#: host allowlist. The false-ish words are spelled out too so a ``=0``/``=false`` no longer 

142#: enables the opt-in by mere string-truthiness. 

143_REMOTE_OPT_IN_ANY = frozenset({"1", "true", "yes", "on"}) 

144_REMOTE_OPT_IN_OFF = frozenset({"0", "false", "no", "off"}) 

145 

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

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

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

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

150DEFAULT_PROMPT_MODE = "stdin" 

151 

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

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

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

155DEFAULT_MODEL_ARG = "--model" 

156 

157__all__ = [ 

158 "SLOTS", 

159 "DEFAULT_EXTENSIONS_DIR", 

160 "DELEGATE_PROFILE_VENDORS", 

161 "LOOPBACK_HOSTS", 

162 "resolved_address_refusal", 

163 "ALLOW_REMOTE_ENDPOINT_ENV", 

164 "DELEGATE_PROMPT_MODES", 

165 "DEFAULT_PROMPT_MODE", 

166 "DEFAULT_MODEL_ARG", 

167 "Automation", 

168 "DelegateProfile", 

169 "Knobs", 

170 "ProjectConfig", 

171 "ConfigError", 

172 "load_config", 

173 "parse_config", 

174 "validate_data", 

175 "load_schema", 

176 "config_hash", 

177 "delegate_profiles_dict", 

178 "vendor_label_errors", 

179] 

180 

181 

182class ConfigError(ValueError): 

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

184 

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

186 self.source = source 

187 self.errors = list(errors) 

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

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

190 

191 

192def load_schema() -> dict: 

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

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

195 

196 

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

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

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

200 

201 

202@dataclass(frozen=True) 

203class DelegateProfile: 

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

205 

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

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

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

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

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

211 """ 

212 

213 vendor: str 

214 command: str | None = None 

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

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

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

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

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

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

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

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

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

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

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

226 prompt_mode: str = DEFAULT_PROMPT_MODE 

227 model: str | None = None 

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

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

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

231 model_arg: str = DEFAULT_MODEL_ARG 

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

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

234 endpoint: str | None = None 

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

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

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

238 api_key_env: str | None = None 

239 #: What ``agent:<vendor>`` should say for this entry, when ``vendor`` — which the 

240 #: schema restricts to ``cli``/``openai-compatible`` — is the transport rather than 

241 #: the model's maker (#1129). Two ``cli`` profiles driving Grok and GPT through the 

242 #: same binary are otherwise the same ``agent:cli``, and 

243 #: ``review-vendor-distinctness`` cannot tell them apart. Unset means unchanged: 

244 #: the label stays ``agent:<vendor>``. 

245 vendor_label: str | None = None 

246 

247 def label_vendor(self) -> str: 

248 """The vendor this entry's attribution names: ``vendor_label`` when set.""" 

249 return self.vendor_label or self.vendor 

250 

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

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

253 if review and self.review_args is not None: 

254 return self.review_args 

255 return self.args 

256 

257 

258@dataclass(frozen=True) 

259class Knobs: 

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

261 

262 #: ``None`` when the project has not chosen one (#1328): a scaffold that found no test 

263 #: command writes none rather than a ``make test`` that cannot run. The ``build`` gate 

264 #: is still planned, and :func:`keel.gates.run_gates` blocks it with a finding naming 

265 #: this knob — unset is never a pass. 

266 build_gate_cmd: str | None 

267 lint_cmd: str | None = None 

268 #: **Deprecated** by ``team.implement.by_role`` (#1014); still accepted and mapped 

269 #: onto it by :func:`keel.team.legacy_seats`. 

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

271 #: Who implements, who gates, who reviews, and when the jury is the panel. 

272 team: team_policy.TeamPolicy = field(default_factory=team_policy.TeamPolicy) 

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

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

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

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

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

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

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

280 sot_doc: str | None = None 

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

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

283 evidence_gate_label: str = "keel:ship" 

284 #: Tri-state on purpose, but **opt-in**: ``None`` is *unset* and resolves to ``False`` 

285 #: on every tier (:func:`keel.team.require_distinct_vendors`, #1065). The tri-state is 

286 #: kept so an explicit ``false`` stays distinguishable from silence for the wizard and 

287 #: for anything that reports what a project actually said. 

288 evidence_require_distinct_vendors: bool | None = None 

289 #: **Deprecated — no effect since #1287.** It used to let ``swarm-land`` skip the 

290 #: per-PR review-evidence check (#828) before a local merge. ``swarm-land`` now lands 

291 #: each cluster's pull request through ``keel merge``, whose evidence gate has no 

292 #: opt-out, so ``false`` changes nothing; it is still parsed (a config that sets it 

293 #: keeps validating) and ``swarm-land`` announces it as ignored on stderr (#1410). 

294 swarm_review_evidence: bool = True 

295 #: The s4 implement profile: ``default`` (one pass) or ``tdd`` (test-first, two 

296 #: phases, with the pure ``tdd-order`` gate at s8). See :mod:`keel.tdd`. 

297 implement_mode: str = tdd_mode.DEFAULT_MODE 

298 #: The s4 iteration policy (#1165): ``None`` when the project never wrote a ``loop`` 

299 #: block. Kept as the mapping the project wrote — the schema owns its shape and 

300 #: :func:`keel.loop.resolve` reads it — so an added optional knob cannot rotate 

301 #: ``config_hash`` for a project that never set it. 

302 loop: dict | None = None 

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

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

305 gate_timeout_s: int = DEFAULT_GATE_TIMEOUT_S 

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

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

308 jury_timeout_s: int = DEFAULT_JURY_TIMEOUT_S 

309 #: The opt-in ``revert-check`` gate's settings (#1289): ``None`` when the project never 

310 #: wrote the block. Kept as the mapping the project wrote, like ``loop`` — the schema 

311 #: owns its shape and :func:`keel.revertcheck.resolve` reads it — so the added knob 

312 #: cannot rotate ``config_hash`` for a project that never set it. 

313 revert_check: dict | None = None 

314 

315 

316@dataclass(frozen=True) 

317class Automation: 

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

319 

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

321 operator: str | None = None 

322 

323 

324@dataclass(frozen=True) 

325class ProjectConfig: 

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

327 

328 extends: str 

329 core_version: str 

330 base_branch: str 

331 knobs: Knobs 

332 owner: str | None = None 

333 repo: str | None = None 

334 platform: str | None = None 

335 timezone: str | None = None 

336 merge_window: str | None = None 

337 merge_window_mode: str = "freeze" 

338 consent_mode: str = "explicit" 

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

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

341 extensions_dir: str = DEFAULT_EXTENSIONS_DIR 

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

343 automation: Automation = field(default_factory=Automation) 

344 

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

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

347 if name not in SLOTS: 

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

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

350 

351 

352def timezone_issue(timezone: str) -> str | None: 

353 """Why ``timezone`` cannot be evaluated on this machine, or ``None`` if it can. 

354 

355 The sentence carries no ``$.`` path so that both callers can frame it themselves: 

356 :func:`_merge_window_issues` prefixes ``$.timezone`` for the :class:`ConfigError`, 

357 and ``keel init --wizard`` prints it as-is before asking the question again (#1082). 

358 One wording in one place is the point — a wizard that phrased the rule in its own 

359 words would drift away from the validator that actually decides. 

360 """ 

361 try: 

362 ZoneInfo(timezone) 

363 except (ValueError, ZoneInfoNotFoundError): 

364 return ( 

365 f"{timezone!r} is not a zone this machine can resolve; give an " 

366 "IANA name such as 'Europe/Istanbul' or 'Etc/GMT-3'" 

367 ) 

368 return None 

369 

370 

371@lru_cache(maxsize=1) 

372def merge_window_pattern() -> re.Pattern[str]: 

373 """The bundled schema's own ``merge_window`` ``pattern``, compiled. 

374 

375 Read out of ``project.schema.json`` instead of restated here, because a second 

376 spelling of the same rule is a second rule waiting to disagree with the first: the 

377 schema said two-digit hours while :func:`keel.window.parse_window` happily read 

378 ``9:00-18:00``, so ``keel init --wizard`` accepted a value ``keel validate`` 

379 immediately refused (#1082). Cached because the answer is package data that cannot 

380 change inside a run, and :func:`merge_window_issue` is asked once per config load. 

381 """ 

382 return re.compile(load_schema()["properties"]["merge_window"]["pattern"]) 

383 

384 

385def merge_window_issue(merge_window: str) -> str | None: 

386 """Why ``merge_window`` is not a window keel accepts, or ``None`` if it is. 

387 

388 Unprefixed for the same reason as :func:`timezone_issue`. Three rules, all of which 

389 ``keel validate`` applies to a hand-written config, so every caller — the wizard 

390 included — accepts exactly what the validator accepts: 

391 

392 * the **shape**, :func:`merge_window_pattern`, taken from the schema itself; 

393 * the **meaning**, :func:`keel.window.parse_window` — the function evaluation time 

394 asks. Today the pattern is the stricter of the two and subsumes it; the call 

395 stays because the schema is a contract that may be relaxed, and the day it is, 

396 a window that will raise mid-ship must still be refused here rather than written. 

397 * the **span**: the two ends must differ. :func:`keel.window.is_merge_open` answers 

398 ``opens <= now < closes`` for a forward window, an interval that is empty when the 

399 two are equal — so ``09:00-09:00`` passed both rules above, ``keel validate`` 

400 printed ``OK``, and every ``keel ship`` then deferred at the merge gate at every 

401 instant of every day, silently and for ever (#1091). 

402 

403 The span rule is the one a ``pattern`` cannot state — a regex relates no capture to 

404 another — which is why the schema cannot own it and this function must. Only the 

405 degenerate equal case is refused: a **wrap-around** window is untouched, so 

406 ``22:00-06:00`` stays the all-night window it reads as, and ``09:00-09:01`` stays a 

407 one-minute window somebody may well have meant. 

408 

409 Normalising ``9:00`` to ``09:00`` was the alternative and was rejected: it would 

410 quietly rewrite the operator's answer, and it would leave the wizard and 

411 ``keel validate`` still disagreeing about what a valid window *is*. 

412 """ 

413 times = _parsed(merge_window) 

414 if merge_window_pattern().fullmatch(merge_window) is None or times is None: 

415 return ( 

416 f"{merge_window!r} is not a valid window; give " 

417 "'HH:MM-HH:MM' with two-digit hours 00-23 and minutes 00-59 " 

418 "(it may wrap midnight)" 

419 ) 

420 opens, closes = times 

421 if opens == closes: 

422 return ( 

423 f"{merge_window!r} opens and closes at the same minute, so it is never open " 

424 "and every merge would defer as outside the merge window, for ever; give a " 

425 "range instead — a window may wrap midnight, so '09:00-08:59' is every " 

426 "minute but that one — or drop 'merge_window' and 'timezone' to configure " 

427 "no window at all" 

428 ) 

429 return None 

430 

431 

432def _parsed(merge_window: str) -> tuple[time, time] | None: 

433 """The ``(opens, closes)`` pair :func:`keel.window.parse_window` reads, or ``None``. 

434 

435 ``None`` is "``parse_window`` cannot read this", which is what the shape rule needs; 

436 the pair is what the span rule needs. One call answers both, so the two rules cannot 

437 end up reading a different window from the same string. 

438 """ 

439 try: 

440 return parse_window(merge_window) 

441 except ValueError: 

442 return None 

443 

444 

445def _merge_window_issues(data: dict) -> list[str]: 

446 """Semantic errors for the ``timezone`` + ``merge_window`` pair (empty == valid). 

447 

448 The schema owns the *shape* of each key on its own; this owns the *meaning* of the 

449 two together, which is the part that decides whether the ``window_gate`` invariant 

450 can be evaluated at all (#1076): 

451 

452 * They are **all-or-nothing.** Both absent is a project that has not asked for a 

453 window. Exactly one present is a project that asked and will not get one: 

454 :func:`keel.ship.assess` and ``keel window`` both read a missing half as "no 

455 window configured" and report the window *open*, so a config declaring 

456 ``merge_window`` and forgetting ``timezone`` merged straight through the night 

457 it meant to block, silently and with no warning anywhere. 

458 * Each half must **actually evaluate.** ``29:00-01:00`` and ``Definitely/Nowhere`` 

459 both survived to :func:`keel.window.is_merge_open`, where they raised 

460 ``ValueError`` / ``ZoneInfoNotFoundError`` out of the middle of a ship run 

461 instead of the :class:`ConfigError` every other malformed knob produces. 

462 * The window must **have a span.** ``09:00-09:00`` evaluates perfectly well and 

463 answers *closed* at every instant there is, so it validated and then deferred 

464 every merge for ever (#1091). See :func:`merge_window_issue`. 

465 

466 Neither check restates a rule of its own: the timezone one asks ``ZoneInfo``, and 

467 the window one asks the schema's own ``pattern`` and then ``parse_window`` 

468 (:func:`merge_window_issue`), so the contract consumers read and the validator that 

469 decides cannot drift apart — which they had, over the single-digit hour in 

470 ``9:00-18:00``: ``parse_window`` read it, the schema refused it, and the wizard 

471 believed ``parse_window`` (#1082). 

472 """ 

473 errors: list[str] = [] 

474 timezone = data.get("timezone") 

475 merge_window = data.get("merge_window") 

476 if isinstance(merge_window, str) and timezone is None: 

477 errors.append( 

478 f"$.merge_window: {merge_window!r} is set but 'timezone' is not; the merge " 

479 "window is evaluated in the project timezone, and with none configured every " 

480 "hour reads as open — add an IANA 'timezone' or drop 'merge_window'" 

481 ) 

482 if isinstance(timezone, str) and merge_window is None: 

483 errors.append( 

484 f"$.timezone: {timezone!r} is set but 'merge_window' is not; a timezone on its " 

485 "own gates nothing — add 'merge_window: HH:MM-HH:MM' or drop 'timezone'" 

486 ) 

487 if isinstance(timezone, str): 

488 issue = timezone_issue(timezone) 

489 if issue is not None: 

490 errors.append(f"$.timezone: {issue}") 

491 if isinstance(merge_window, str): 

492 issue = merge_window_issue(merge_window) 

493 if issue is not None: 

494 errors.append(f"$.merge_window: {issue}") 

495 return errors 

496 

497 

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

499 k = data["knobs"] 

500 knobs = Knobs( 

501 build_gate_cmd=k.get("build_gate_cmd"), 

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

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

504 team=team_policy.parse_team(k.get("team")), 

505 delegate_profiles={ 

506 name: DelegateProfile( 

507 vendor=profile["vendor"], 

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

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

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

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

512 review_args=( 

513 tuple(profile["review_args"]) 

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

515 else None 

516 ), 

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

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

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

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

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

522 vendor_label=profile.get("vendor_label"), 

523 ) 

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

525 }, 

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

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

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

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

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

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

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

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

534 evidence_require_distinct_vendors=( 

535 None 

536 if k.get("evidence_require_distinct_vendors") is None 

537 else bool(k["evidence_require_distinct_vendors"]) 

538 ), 

539 implement_mode=k.get("implement_mode", tdd_mode.DEFAULT_MODE), 

540 loop=dict(k["loop"]) if isinstance(k.get("loop"), dict) else None, 

541 swarm_review_evidence=bool(k.get("swarm_review_evidence", True)), 

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

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

544 revert_check=(dict(k["revert_check"]) if isinstance(k.get("revert_check"), dict) else None), 

545 ) 

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

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

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

549 return ProjectConfig( 

550 extends=data["extends"], 

551 core_version=data["core_version"], 

552 base_branch=data["base_branch"], 

553 knobs=knobs, 

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

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

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

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

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

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

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

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

562 extensions=extensions, 

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

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

565 automation=Automation( 

566 approved_scopes=tuple(sorted(automation_scopes)), 

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

568 ), 

569 ) 

570 

571 

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

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

574 if not isinstance(data, dict): 

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

576 errors = validate_data(data, schema) 

577 errors.extend(_merge_window_issues(data)) 

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

579 knobs = data["knobs"] 

580 errors.extend( 

581 validate_names( 

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

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

584 ) 

585 ) 

586 errors.extend( 

587 validate_names( 

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

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

590 ) 

591 ) 

592 errors.extend( 

593 _validate_delegate_profiles( 

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

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

596 ) 

597 ) 

598 errors.extend( 

599 team_policy.team_issues( 

600 knobs.get("team"), 

601 source=f"{source}: knobs.team", 

602 profiles=_profile_vendors(knobs.get("delegate_profiles", {})), 

603 implementer_agents=_role_agents(knobs.get("implementer_agents", {})), 

604 ) 

605 ) 

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

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

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

609 # Validated here rather than where the file is written: a template naming 

610 # `{repoo}` is a typo whose only symptom would otherwise be a directory by 

611 # that name, created successfully, on a machine nobody is watching. 

612 errors.extend(f"{source}: {issue}" for issue in _learning_sink_issues(data["policy_pack"])) 

613 if errors: 

614 raise ConfigError(source, errors) 

615 return _build(data) 

616 

617 

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

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

620 path = Path(path) 

621 try: 

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

623 except yaml.YAMLError as exc: 

624 raise ConfigError(str(path), [f"YAML syntax error: {exc}"]) from exc 

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

626 

627 

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

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

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

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

632 

633 

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

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

636 return bool(value) and not value[0].isdigit() and all(ch.isalnum() or ch == "_" for ch in value) 

637 

638 

639BLOCKED_METADATA_HOSTS = frozenset( 

640 { 

641 "169.254.169.254", 

642 "metadata.google.internal", 

643 "instance-data", 

644 "metadata", 

645 } 

646) 

647 

648 

649#: Set to ``1`` to reach RFC1918 space on purpose — a self-hosted vLLM on the 

650#: same subnet is a legitimate deployment, and #866 specified an opt-out rather 

651#: than a refusal. Distinct from :data:`ALLOW_REMOTE_ENDPOINT_ENV`: that one is 

652#: about reaching *outward* at all, this one about reaching *inward*. 

653ALLOW_INTERNAL_ENDPOINT_ENV = "KEEL_ALLOW_INTERNAL_ENDPOINT" 

654 

655 

656def _as_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: 

657 """The address a client will actually dial, or ``None`` if the host is a name. 

658 

659 ``ipaddress.ip_address`` only accepts the dotted-quad spelling, so 

660 ``http://2852039166/`` — the decimal form of ``169.254.169.254`` — raised, 

661 the guard fell through, and the request reached the metadata service anyway 

662 (#929). Octal (``0251.0376.0251.0376``) and hex (``0xA9FEA9FE``) do the same. 

663 

664 ``socket.inet_aton`` accepts exactly the forms a C resolver does, which is 

665 what libcurl and the Python HTTP stack ultimately call — so normalising 

666 through it asks the same question the client will ask, rather than a 

667 stricter one that a caller can step around. 

668 """ 

669 try: 

670 return ipaddress.ip_address(host) 

671 except ValueError: 

672 pass 

673 try: 

674 return ipaddress.IPv4Address(socket.inet_aton(host)) 

675 except (OSError, ipaddress.AddressValueError): 

676 return None 

677 

678 

679def _is_cloud_metadata_or_link_local(host: str) -> bool: 

680 if host in BLOCKED_METADATA_HOSTS: 

681 return True 

682 ip = _as_ip(host) 

683 return bool(ip is not None and ip.is_link_local) 

684 

685 

686def _is_private_address(host: str) -> bool: 

687 """Whether ``host`` names RFC1918 / loopback / unique-local space. 

688 

689 #866's second measure, which never shipped: with the remote-endpoint opt-in 

690 set, ``10.0.0.5``, ``172.16.5.9`` and ``192.168.1.10`` were all reachable 

691 from a config-supplied endpoint (#929). 

692 """ 

693 ip = _as_ip(host) 

694 if ip is None: 

695 return False 

696 return bool(ip.is_private or ip.is_loopback) 

697 

698 

699def resolved_address_refusal(host: str, ip: Any, *, env=None) -> str | None: 

700 """Why a name's *resolved* address may not be connected to, or ``None``. 

701 

702 :func:`endpoint_issues` classifies the host as written. A hostname carries no 

703 address, so `_as_ip` returns ``None`` and both the metadata and the private 

704 checks answer "no" — leaving a name free to reach what its literal spelling 

705 could not (#969, found by the ai-jury panel on #958): 

706 

707 ALLOW_REMOTE on, ALLOW_INTERNAL off: 

708 refused http://10.0.0.5/ literal RFC1918 

709 allowed http://<name resolving to 10.0.0.5>/ the same target 

710 

711 That is exactly the boundary the refusal text draws — *"permits reaching out, 

712 not reaching in"* — so this applies the same rule to whatever the name 

713 actually resolved to. It is a *policy* function on purpose: the resolution 

714 and the connection belong at the I/O edge (:mod:`keel.api_delegate`), and 

715 keeping the decision here is what lets it be tested without a network. 

716 

717 ``host`` is still needed alongside ``ip``: a literal loopback endpoint is the 

718 default-allowed case for a local model server, and refusing 127.0.0.1 would 

719 break ``http://localhost:11434``. A *name* that resolves to loopback is a 

720 different act — it only passed the earlier gate because the remote opt-in was 

721 set — so it is held to the internal opt-in like any other reach-in. 

722 """ 

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

724 if ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified: 

725 return ( 

726 f"resolves to {ip}, which is link-local, multicast or reserved space; " 

727 "refused whatever the name" 

728 ) 

729 if not (ip.is_private or ip.is_loopback): 

730 return None 

731 if host in LOOPBACK_HOSTS or env.get(ALLOW_INTERNAL_ENDPOINT_ENV): 

732 return None 

733 return ( 

734 f"resolves to {ip}, a private or loopback address. " 

735 f"{ALLOW_REMOTE_ENDPOINT_ENV} permits reaching out, not reaching in. Set " 

736 f"{ALLOW_INTERNAL_ENDPOINT_ENV}=1 in the environment for a model server on " 

737 "your own network" 

738 ) 

739 

740 

741def _remote_endpoint_status(env, host: str) -> str: 

742 """How the operator's remote opt-in applies to ``host``. 

743 

744 ``KEEL_ALLOW_REMOTE_ENDPOINT`` is either a boolean (allow any remote host) or a host 

745 allowlist; see :data:`ALLOW_REMOTE_ENDPOINT_ENV`. Returns one of ``permitted-any``, 

746 ``permitted-listed``, ``refused-off`` (not set, blank, or a false-ish word) or 

747 ``refused-unlisted`` (an allowlist is set and ``host`` is not on it). 

748 """ 

749 raw = str(env.get(ALLOW_REMOTE_ENDPOINT_ENV) or "") 

750 tokens = [token.strip().lower() for token in re.split(r"[,\s]+", raw) if token.strip()] 

751 if not tokens or (len(tokens) == 1 and tokens[0] in _REMOTE_OPT_IN_OFF): 

752 return "refused-off" 

753 if len(tokens) == 1 and tokens[0] in _REMOTE_OPT_IN_ANY: 

754 return "permitted-any" 

755 return "permitted-listed" if host in tokens else "refused-unlisted" 

756 

757 

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

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

760 

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

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

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

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

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

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

767 

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

769 and the other SSRF primitives; 

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

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

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

773 purpose: an attacker who can edit config must not be able to grant it. The opt-in 

774 may name the allowed host(s) rather than a bare ``1``, so a config that then points 

775 the endpoint elsewhere is refused even while the opt-in is set (#1247). 

776 

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

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

779 """ 

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

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

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

783 try: 

784 parsed = urlsplit(endpoint) 

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

786 except ValueError: 

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

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

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

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

791 return [ 

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

793 "use http or https" 

794 ] 

795 if host in LOOPBACK_HOSTS: 

796 return [] 

797 if _is_cloud_metadata_or_link_local(host): 

798 return [ 

799 f"{where}: endpoint host {host or '(none)'!r} is a cloud-metadata or link-local " 

800 "address and is refused for security" 

801 ] 

802 remote = _remote_endpoint_status(env, host) 

803 if remote == "refused-off": 

804 return [ 

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

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

807 f"by default. Set {ALLOW_REMOTE_ENDPOINT_ENV} in the environment — not in this " 

808 "file — to a trusted host or comma-separated list of hosts (or to 1 for any " 

809 "remote host)" 

810 ] 

811 if remote == "refused-unlisted": 

812 return [ 

813 f"{where}: endpoint host {host or '(none)'!r} is not one of the hosts named in " 

814 f"{ALLOW_REMOTE_ENDPOINT_ENV}; a config cannot redirect the endpoint to a host " 

815 "the environment did not allow" 

816 ] 

817 # #866's second measure, which never shipped: with the remote opt-in set, 

818 # 10.0.0.5, 172.16.5.9 and 192.168.1.10 were all reachable from a 

819 # config-supplied endpoint (#929). Checked *after* the remote gate so the 

820 # error names the narrower opt-out the operator actually needs. 

821 if _is_private_address(host) and not env.get(ALLOW_INTERNAL_ENDPOINT_ENV): 

822 return [ 

823 f"{where}: endpoint host {host or '(none)'!r} is a private or loopback " 

824 f"address. {ALLOW_REMOTE_ENDPOINT_ENV} permits reaching out, not reaching " 

825 f"in. Set {ALLOW_INTERNAL_ENDPOINT_ENV}=1 in the environment — not in this " 

826 "file — for a model server on your own network" 

827 ] 

828 return [] 

829 

830 

831def _role_agents(role_agents: Any) -> dict[str, str]: 

832 """``knobs.implementer_agents`` reduced to its well-formed ``str -> str`` entries.""" 

833 if not isinstance(role_agents, dict): 

834 return {} 

835 return { 

836 role: agent 

837 for role, agent in role_agents.items() 

838 if isinstance(role, str) and isinstance(agent, str) 

839 } 

840 

841 

842def _profile_vendors(profiles: Any) -> dict[str, str]: 

843 """``{profile name: vendor}`` for the well-formed entries of ``delegate_profiles``. 

844 

845 Deliberately forgiving: a malformed profile is already reported by 

846 :func:`_validate_delegate_profiles`, and ``knobs.team`` must not add a second, 

847 confusing "unknown provider" error for the same typo. 

848 """ 

849 if not isinstance(profiles, dict): 

850 return {} 

851 return { 

852 name: profile["vendor"] 

853 for name, profile in profiles.items() 

854 if isinstance(name, str) 

855 and isinstance(profile, dict) 

856 and isinstance(profile.get("vendor"), str) 

857 } 

858 

859 

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

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

862 

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

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

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

866 """ 

867 errors: list[str] = [] 

868 if not isinstance(profiles, dict): 

869 return errors # the schema already reported the wrong shape 

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

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

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

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

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

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

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

877 if not isinstance(name, str): 

878 errors.append( 

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

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

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

882 ) 

883 continue 

884 if name in BUILTIN_DELEGATE_VENDORS: 

885 errors.append( 

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

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

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

889 ) 

890 elif name in DELEGATE_PROFILE_VENDORS: 

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

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

893 # ambiguity the field was added to remove. 

894 errors.append( 

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

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

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

898 "'cursor'" 

899 ) 

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

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

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

903 if not name.strip(): 

904 errors.append( 

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

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

907 ) 

908 elif ":" in name: 

909 errors.append( 

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

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

912 "name could never be selected" 

913 ) 

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

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

916 vendor = profile["vendor"] 

917 if vendor not in DELEGATE_PROFILE_VENDORS: 

918 errors.append( 

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

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

921 ) 

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

923 errors.append( 

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

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

926 ) 

927 elif vendor in _ENDPOINT_VENDORS: 

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

929 key_env = profile.get("api_key_env") 

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

931 errors.append( 

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

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

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

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

936 ) 

937 elif not _is_env_var_name(key_env): 

938 errors.append( 

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

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

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

942 ) 

943 elif key_env.upper() in BLOCKED_ENV_KEY_NAMES or key_env.upper().startswith( 

944 BLOCKED_ENV_PREFIXES 

945 ): 

946 errors.append( 

947 f"{where}: api_key_env {key_env!r} refers to a sensitive system " 

948 "credential and is refused for security" 

949 ) 

950 elif not _is_allowed_api_key_env(key_env): 

951 errors.append( 

952 f"{where}: api_key_env {key_env!r} is not an allowed delegate key. " 

953 f"Use one of {', '.join(sorted(ALLOWED_ENV_KEY_NAMES))}, or prefix a " 

954 f"project-specific name with {ALLOWED_ENV_KEY_PREFIX} — the config " 

955 "names the variable whose value becomes an Authorization header, so " 

956 "it may only name variables meant to hold a model-API key" 

957 ) 

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

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

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

961 # both fields are legal *somewhere*. 

962 for field_name, owners in ( 

963 ("command", _COMMAND_VENDORS), 

964 ("endpoint", _ENDPOINT_VENDORS), 

965 ("api_key_env", _ENDPOINT_VENDORS), 

966 ): 

967 if ( 

968 profile.get(field_name) 

969 and vendor in DELEGATE_PROFILE_VENDORS 

970 and vendor not in owners 

971 ): 

972 errors.append( 

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

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

975 ) 

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

977 if prompt_mode not in DELEGATE_PROMPT_MODES: 

978 errors.append( 

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

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

981 ) 

982 errors.extend(vendor_label_errors(profile.get("vendor_label"), where=where)) 

983 return errors 

984 

985 

986def vendor_label_errors(label: Any, *, where: str) -> list[str]: 

987 """Rules for a ``vendor_label`` — shared by project profiles and the registry (#1129). 

988 

989 The value becomes ``agent:<label>``, a GitHub label keel *applies* and 

990 ``attribution_check`` later reads back. So the rules are about what may be written 

991 into that vocabulary, not about the value's spelling for its own sake: 

992 

993 * it may not shadow a built-in delegate vendor, because the built-in writes the same 

994 label from a different provider — which is the ambiguity #1129 is about, inverted; 

995 * it may not restate the generic vendor it exists to replace (``cli``), because that 

996 is the label the entry already gets and setting it reads as an intent that has no 

997 effect; 

998 * and it is restricted to the characters every existing vendor token uses, so the 

999 label keel writes is the label ``attribution_check`` can match. 

1000 """ 

1001 if label is None: 

1002 return [] 

1003 if not isinstance(label, str) or not label.strip(): 

1004 return [ 

1005 f"{where}: vendor_label must be a non-empty string — the vendor name that " 

1006 "goes in the agent:<vendor> label, e.g. 'xai'" 

1007 ] 

1008 if label in BUILTIN_DELEGATE_VENDORS: 

1009 return [ 

1010 f"{where}: vendor_label {label!r} is a built-in delegate vendor, which writes " 

1011 f"agent:{label} from a different provider — two providers sharing one label is " 

1012 "the ambiguity this field exists to remove. Name the model's maker, e.g. 'xai'" 

1013 ] 

1014 if label in DELEGATE_PROFILE_VENDORS: 

1015 return [ 

1016 f"{where}: vendor_label {label!r} is what the label already says; leave it " 

1017 "unset, or name the model's maker (e.g. 'xai') so agent:<vendor> distinguishes " 

1018 "this entry from another entry with the same transport" 

1019 ] 

1020 if not _VENDOR_LABEL_OK.issuperset(label): 

1021 bad = "".join(sorted(set(label) - _VENDOR_LABEL_OK)) 

1022 return [ 

1023 f"{where}: vendor_label {label!r} contains {bad!r}; it becomes the GitHub " 

1024 "label agent:<vendor>, so use lowercase letters, digits, '.', '-' or '_'" 

1025 ] 

1026 return [] 

1027 

1028 

1029def _learning_sink_issues(policy_pack: dict[str, Any]) -> list[str]: 

1030 """Problems in `policy_pack.capture.learning`'s write and read paths, or `[]`.""" 

1031 capture_policy = policy_pack.get("capture") 

1032 if not isinstance(capture_policy, dict): 

1033 return [] 

1034 learning = capture_policy.get("learning") 

1035 if not isinstance(learning, dict): 

1036 return [] 

1037 return capture.learning_sink_errors(learning.get("sink")) + capture.learning_source_errors( 

1038 learning.get("source") 

1039 ) 

1040 

1041 

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

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

1044 if isinstance(value, dict): 

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

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

1047 if key in {"required_capabilities", "optional_capabilities"} and isinstance( 

1048 child, list 

1049 ): 

1050 fields.append((child_path, child)) 

1051 else: 

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

1053 elif isinstance(value, list): 

1054 for i, child in enumerate(value): 

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

1056 return fields 

1057 

1058 

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

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

1061 

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

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

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

1065 used it. 

1066 """ 

1067 profiles = config.knobs.delegate_profiles 

1068 if not profiles: 

1069 return {} 

1070 return { 

1071 "delegate_profiles": { 

1072 name: { 

1073 "vendor": profile.vendor, 

1074 "command": profile.command, 

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

1076 "review_args": ( 

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

1078 ), 

1079 "prompt_mode": profile.prompt_mode, 

1080 "model": profile.model, 

1081 "model_arg": profile.model_arg, 

1082 "endpoint": profile.endpoint, 

1083 "api_key_env": profile.api_key_env, 

1084 # Emitted only when set: this dict is hashed into `config_hash`, and an 

1085 # added optional field must not rotate the hash for a project that never 

1086 # used it. Same rule as `delegate_profiles` itself being omitted when empty. 

1087 **({"vendor_label": profile.vendor_label} if profile.vendor_label else {}), 

1088 } 

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

1090 } 

1091 } 

1092 

1093 

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

1095 return { 

1096 "extends": config.extends, 

1097 "core_version": config.core_version, 

1098 "base_branch": config.base_branch, 

1099 "owner": config.owner, 

1100 "repo": config.repo, 

1101 "platform": config.platform, 

1102 "timezone": config.timezone, 

1103 "merge_window": config.merge_window, 

1104 "merge_window_mode": config.merge_window_mode, 

1105 "consent_mode": config.consent_mode, 

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

1107 "extensions_dir": config.extensions_dir, 

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

1109 "policy_pack": config.policy_pack, 

1110 "automation": { 

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

1112 "operator": config.automation.operator, 

1113 }, 

1114 "knobs": { 

1115 "build_gate_cmd": config.knobs.build_gate_cmd, 

1116 "lint_cmd": config.knobs.lint_cmd, 

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

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

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

1120 # normal treatment for an added optional field. `team` is omitted on the same 

1121 # rule, which is what makes "config_hash changes iff team changes" true. 

1122 **delegate_profiles_dict(config), 

1123 **team_policy.canonical(config.knobs.team), 

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

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

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

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

1128 "sot_doc": config.knobs.sot_doc, 

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

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

1131 "evidence_gate_label": config.knobs.evidence_gate_label, 

1132 # bool(), not the tri-state: an unset knob has always hashed as False, and 

1133 # adding the "unset" spelling must not rotate config_hash for every project. 

1134 "evidence_require_distinct_vendors": bool( 

1135 config.knobs.evidence_require_distinct_vendors 

1136 ), 

1137 # Omitted while it is the default, on the `delegate_profiles` / `team` rule: 

1138 # an added optional knob must not rotate config_hash for the projects that 

1139 # never set it — and config_hash changes whenever implement_mode does. 

1140 **( 

1141 {"implement_mode": config.knobs.implement_mode} 

1142 if config.knobs.implement_mode != tdd_mode.DEFAULT_MODE 

1143 else {} 

1144 ), 

1145 # Same rule (#1165): present only when the project wrote it. 

1146 **({"loop": dict(config.knobs.loop)} if config.knobs.loop is not None else {}), 

1147 "swarm_review_evidence": config.knobs.swarm_review_evidence, 

1148 "gate_timeout_s": config.knobs.gate_timeout_s, 

1149 "jury_timeout_s": config.knobs.jury_timeout_s, 

1150 # Same rule as `loop` (#1289): present only when the project wrote it. 

1151 **( 

1152 {"revert_check": dict(config.knobs.revert_check)} 

1153 if config.knobs.revert_check is not None 

1154 else {} 

1155 ), 

1156 }, 

1157 }