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
« 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.
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"""
9from __future__ import annotations
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
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
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
41SCHEMA_PATH = Path(__file__).parent / "schema" / "project.schema.json"
43DEFAULT_EXTENSIONS_DIR = ".keel/extensions"
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")
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.-_")
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",)
61#: Hosts an ``openai-compatible`` endpoint may use without an explicit opt-in.
62LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1", "[::1]")
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)
81BLOCKED_ENV_PREFIXES = (
82 "GITHUB_",
83 "GH_",
84 "AWS_",
85 "NPM_",
86 "PYPI_",
87 "SSH_",
88)
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)
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_"
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)
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"
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"})
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"
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"
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]
182class ConfigError(ValueError):
183 """Raised when a project config fails schema validation."""
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}")
192def load_schema() -> dict:
193 """Load the bundled JSON Schema for ``project.yaml``."""
194 return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
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())
202@dataclass(frozen=True)
203class DelegateProfile:
204 """A named generic-delegate vendor, referenced as ``--delegate <name>``.
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 """
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
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
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
258@dataclass(frozen=True)
259class Knobs:
260 """Per-project values consumed by the (otherwise neutral) backbone steps."""
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
316@dataclass(frozen=True)
317class Automation:
318 """Trusted unattended-run consent defaults."""
320 approved_scopes: tuple[str, ...] = ()
321 operator: str | None = None
324@dataclass(frozen=True)
325class ProjectConfig:
326 """A resolved, immutable keel project config."""
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)
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, ())
352def timezone_issue(timezone: str) -> str | None:
353 """Why ``timezone`` cannot be evaluated on this machine, or ``None`` if it can.
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
371@lru_cache(maxsize=1)
372def merge_window_pattern() -> re.Pattern[str]:
373 """The bundled schema's own ``merge_window`` ``pattern``, compiled.
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"])
385def merge_window_issue(merge_window: str) -> str | None:
386 """Why ``merge_window`` is not a window keel accepts, or ``None`` if it is.
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:
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).
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.
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
432def _parsed(merge_window: str) -> tuple[time, time] | None:
433 """The ``(opens, closes)`` pair :func:`keel.window.parse_window` reads, or ``None``.
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
445def _merge_window_issues(data: dict) -> list[str]:
446 """Semantic errors for the ``timezone`` + ``merge_window`` pair (empty == valid).
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):
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`.
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
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 )
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)
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))
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()
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)
639BLOCKED_METADATA_HOSTS = frozenset(
640 {
641 "169.254.169.254",
642 "metadata.google.internal",
643 "instance-data",
644 "metadata",
645 }
646)
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"
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.
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.
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
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)
686def _is_private_address(host: str) -> bool:
687 """Whether ``host`` names RFC1918 / loopback / unique-local space.
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)
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``.
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):
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
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.
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 )
741def _remote_endpoint_status(env, host: str) -> str:
742 """How the operator's remote opt-in applies to ``host``.
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"
758def endpoint_issues(endpoint: Any, *, where: str, env=None) -> list[str]:
759 """Validate an ``openai-compatible`` endpoint URL. Empty list == acceptable.
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:
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).
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 []
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 }
842def _profile_vendors(profiles: Any) -> dict[str, str]:
843 """``{profile name: vendor}`` for the well-formed entries of ``delegate_profiles``.
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 }
860def _validate_delegate_profiles(profiles: Any, *, source: str) -> list[str]:
861 """Return semantic errors for ``knobs.delegate_profiles`` (empty == valid).
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
986def vendor_label_errors(label: Any, *, where: str) -> list[str]:
987 """Rules for a ``vendor_label`` — shared by project profiles and the registry (#1129).
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:
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 []
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 )
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
1059def delegate_profiles_dict(config: ProjectConfig) -> dict:
1060 """``{"delegate_profiles": {...}}``, or ``{}`` when none are configured.
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 }
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 }